Compare commits

...

14 Commits

Author SHA1 Message Date
Srikanth Chekuri
1795a0fa36 Merge branch 'main' into feat/quick-filters-fields-values 2026-09-08 16:56:18 +05:30
Pandey
fe8c9d8cf4 chore: remove query progress tracking (#12784)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- The frontend websocket client for query progress
(`api/common/getQueryStats.ts`) was deleted in #9183, so
`/api/v3/query_progress` and `/ws/query_progress` have had no consumers
since.
- Removes the `query_progress` tracker package, the
`GetQueryProgressUpdates` handler, the `RegisterWebSocketPaths` route
group, and the `websocket.Upgrader` on `APIHandler`.
- Removes the clickhouse reader hooks that reported progress
(`ReportQueryStartForProgressTracking`, `SubscribeToQueryProgress`, and
the `clickhouse.WithProgress` callback in `GetTimeSeriesResultV3`),
along with the untyped `"queryId"` context value they relied on.
- Removes the `X-SIGNOZ-QUERY-ID` header the logs explorer still sent,
plus the now-dead `Sec-WebSocket-Protocol` entries in the CORS allowlist
and the `identn` tokenizer defaults. Livetail streams over SSE, so no
websocket endpoint remains.

#### Additional Information

- `gorilla/websocket` moves from a direct to an indirect dependency
(still pulled in by opamp-go).
- Dropping `Sec-WebSocket-Protocol` from `identn.tokenizer.headers`
changes a default in `conf/example.yaml`. It is only a token-extraction
source for websocket handshakes, of which there are none left, but
calling it out since it is a config-surface change.
2026-09-08 10:56:58 +00:00
dependabot[bot]
2ad3057fa6 chore(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.1 in /scripts/promqltestcorpus (#12758)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from
1.82.1 to 1.83.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/grpc/grpc-go/releases">google.golang.org/grpc's
releases</a>.</em></p>
<blockquote>
<h2>Release 1.83.1</h2>
<h1>Security</h1>
<ul>
<li>xds/rbac: Fix a bug where nested <code>Principal</code> or
<code>Permission</code> rules with <code>:scheme</code> or
<code>grpc-</code> prefixed header matchers were not rejected, which
could cause DENY rules to fail open. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/nvxbug"><code>@​nvxbug</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where the <code>host</code> header matcher was
not being replaced with <code>:authority</code> in nested
<code>Principal</code> or <code>Permission</code> rules. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/nvxbug"><code>@​nvxbug</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a header matcher whose name was not
lowercase, such as <code>X-Role</code>, matched no header, which could
cause DENY rules to fail open. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a <code>:scheme</code> or
<code>grpc-</code> prefixed header matcher was accepted when its name
was not lowercase. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
<li>xds/rbac: Fix a bug where a <code>Host</code> header matcher was not
replaced with <code>:authority</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/alimony"><code>@​alimony</code></a></li>
</ul>
</li>
</ul>
<h1>Performance</h1>
<ul>
<li>transport: Restrict memory overhead of buffering small data frames.
(<a
href="https://redirect.github.com/grpc/grpc-go/issues/9331">#9331</a>)</li>
</ul>
<h2>Release 1.83.0</h2>
<h1>Security</h1>
<ul>
<li>server: Stop reading from connections when flooded by HTTP/2 frames
to mitigate resource exhaustion. The default value for this limit is 100
frames, excluding DATA and HEADERS, and may be changed by setting
environment variable
<code>GRPC_GO_EXPERIMENTAL_CONTROL_BUFFER_THROTTLE_LIMIT</code>.</li>
<li>xds/rbac: Support <code>Metadata</code> and
<code>RequestedServerName</code> permissions matcher fields. If present
in a DENY rule, previously these would be ignored and fail-open.</li>
<li>xds/rbac: Fix panic when parsing unsupported fields in
<code>NotRule</code>/<code>NotId</code> permissions.</li>
<li>xds/rbac: Support the deprecated <code>source_ip</code> principal
identifier by treating it as equivalent to
<code>direct_remote_ip</code>.</li>
<li>xds: Fix panic when parsing route header matchers configured with
empty <code>exact_match</code>, <code>prefix_match</code>, or
<code>suffix_match</code> strings. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9223">#9223</a>)</li>
</ul>
<h1>New Features</h1>
<ul>
<li>xds/googlec2p: Enable DirectPath over Interconnect support for
on-premises clients via the <code>force-xds</code> target URI query
parameter. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9133">#9133</a>)</li>
<li>xds: Enable xDS configuration to control which fields get propagated
from ORCA backend metric reports to LRS load reports. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9145">#9145</a>)</li>
<li>authz: Add <code>OnPolicyUpdate</code> callback to
<code>FileWatcherOptions</code> to notify when an authz policy is loaded
or updated. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9142">#9142</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/hnefatl"><code>@​hnefatl</code></a></li>
</ul>
</li>
<li>xds: Add support for the GCP Authentication HTTP Filter, which
automatically fetches and attaches GCP Service Account Identity JWT
tokens to outgoing RPCs.
<ul>
<li>This feature can be enabled by setting environment variable
<code>GRPC_EXPERIMENTAL_XDS_GCP_AUTHENTICATION_FILTER=true</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9119">#9119</a>)</li>
</ul>
</li>
<li>xds: Add support for xDS-based HTTP CONNECT proxies.
<ul>
<li>This feature can be enabled by setting environment variable
<code>GRPC_EXPERIMENTAL_XDS_HTTP_CONNECT=true</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9151">#9151</a>)</li>
</ul>
</li>
<li>xds: Add support for <code>contains_match</code> in route header
matchers. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9223">#9223</a>)</li>
</ul>
<h1>Bug Fixes</h1>
<ul>
<li>credentials/alts: Fix panic when processing malformed frames by
validating that the message frame length exceeds the message type field
size. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9197">#9197</a>)</li>
<li>grpc: Fix compilation on Plan 9 targets (<code>GOOS=plan9</code>),
broken since v1.81.0. (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9255">#9255</a>)
<ul>
<li>Special Thanks: <a
href="https://github.com/Yusufihsangorgel"><code>@​Yusufihsangorgel</code></a></li>
</ul>
</li>
</ul>
<h2>Release 1.82.2</h2>
<h1>Security</h1>
<ul>
<li>server: Reject requests missing both <code>:authority</code> and
<code>Host</code> headers with HTTP 400 and status
<code>Internal</code>. (<a
href="https://redirect.github.com/grpc/grpc-go/pull/9365">grpc/grpc-go#9365</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="1550d9e0cd"><code>1550d9e</code></a>
Change version to 1.83.1 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9336">#9336</a>)</li>
<li><a
href="ebba6f3f1b"><code>ebba6f3</code></a>
Cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9258">#9258</a>
and <a
href="https://redirect.github.com/grpc/grpc-go/issues/9332">#9332</a>
into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9335">#9335</a>)</li>
<li><a
href="8cfeca0e1e"><code>8cfeca0</code></a>
Cherry-pick <a
href="https://redirect.github.com/grpc/grpc-go/issues/9331">#9331</a> to
v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9333">#9333</a>)</li>
<li><a
href="dec6951305"><code>dec6951</code></a>
Change version to 1.83.1-dev (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9229">#9229</a>)</li>
<li><a
href="4c226daff8"><code>4c226da</code></a>
Change version to 1.83.0 (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9228">#9228</a>)</li>
<li><a
href="c198988aa9"><code>c198988</code></a>
Cherrypick 9223 into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9279">#9279</a>)</li>
<li><a
href="8ce3ebf24a"><code>8ce3ebf</code></a>
Cherrypick PR 9255 into v1.83.x (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9263">#9263</a>)</li>
<li><a
href="e39384978c"><code>e393849</code></a>
Cherry-pick recent changes from master (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9240">#9240</a>)</li>
<li><a
href="2a112a82f5"><code>2a112a8</code></a>
authz: add onPolicyUpdate callback to authz file watcher (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9142">#9142</a>)</li>
<li><a
href="1a80fca960"><code>1a80fca</code></a>
vet: adds a check to disallow usage of regex.Compile in xDS code (<a
href="https://redirect.github.com/grpc/grpc-go/issues/9216">#9216</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.1">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 09:33:33 +00:00
srikanthccv
66fed469ea test(sqlmigration): drop the quick-filter normalization unit test
- the rewrite is covered by the quickfilter integration suite against
  migrated rows

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 14:50:02 +05:30
srikanthccv
c90cdcee81 fix(quickfilter): keep trace_id in the default traces filters
- trace_id stays a default, as a span field, so the default set is the
  same as before apart from the contexts

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 14:43:27 +05:30
Aditya Singh
9fc73fc0b1 feat(quick-filters): migrate keys + save/load to new field APIs (#12717)
#### Description

- quick filter keys now come from fields/keys for all pages (logs,
traces, exceptions, api monitoring, meter)..replaces the old v3
filter_suggestions and aggregate attribute_keys calls
- save/load moved to the new v2 /me/filters api that stores
TelemetryFieldKey (name, fieldContext, fieldDataType). removed the old
hand written v1 clients and types
- filters are now identified by name + fieldContext + fieldDataType so
same name fields with different context/datatype don't clash. kept this
contained to the quick filter settings
- values still use the old v3 path for now..that migration (checkboxv2 /
fields/values) is the next PR

#### Screen recording


https://github.com/user-attachments/assets/130c8054-9f7d-4d8d-b224-62da7ccaf41e



#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/5979
2026-09-08 08:41:06 +00:00
srikanthccv
c5a0a2348e fix(sqlmigration): normalize static quick-filter fields whatever their stored context
- the legacy seeds stored severity_text as a resource key; the rewrite now
  applies to a static field under any stored context

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 13:53:03 +05:30
srikanthccv
a2b1f62995 fix(quickfilter): store span and log fields as the fields API serves them
- the default traces filters name has_error, name, duration_nano,
  response_status_code, http_host and http_url with the span context and
  the static data type; hasError and trace_id are no longer defaults
- migration 126 rewrites the static fields of every stored quick filter to
  the same name, context and data type, for the trace-based sources and
  for logs; attribute and resource keys are untouched
- the quick-filter title map covers has_error

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 13:51:28 +05:30
srikanthccv
ca1540daa4 fix(telemetrymetadata): bool rows, span scope fields, and the tag-table day filter
- bool tag rows no longer sit in the values query: a separate existence
  check decides whether true and false are offered, so the search text
  narrows them and they neither take limit slots nor affect complete
- a static bool field merges with the tag-table values of a same-named
  attribute instead of skipping the query
- isRoot and isEntryPoint come from one registry in the traces schema for
  the keys and values paths; their values are true only, matching the
  condition builder
- the start filter compares toDate(unix_milli / 1000) with the start's
  date, the partition expression, so it follows the server timezone and
  prunes partitions
- the scope context filters the keys table by tag type for traces
- audit keys list the log intrinsics without a keys-table query, as logs do

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 13:51:28 +05:30
srikanthccv
8418abc8c9 fix(quick-filters): include boolValues in the API-backed checkbox options
- the fields/values hook merged string and number values only, so the
  boolValues the API returns for bool fields never became options

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 12:27:17 +05:30
srikanthccv
69b17f60e5 fix(telemetrymetadata): floor the values start filter to the tag table's day
- tag_attributes_v2 deduplicates a value per day and the surviving row
  carries whichever hour was inserted last, so an hour-granular start
  could drop a value seen after the start; the start is floored to the
  UTC day, the partition and deduplication unit
- traces stamp tag rows with the span start hour and logs with the
  ingestion hour; the start-filter integration case covers traces only
  and states why

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 12:27:17 +05:30
srikanthccv
3b6a9b3552 test(fields): parametrize the fields keys and values integration cases
- keys by context for every log and span context, including scope and
  attribute, and the span context keeping attributes
- keys by data type, with number, int64 and float64 as one family
- case-insensitive search with and without a context prefix
- bool values for the calculated span field, a stored bool attribute and a
  caller-declared bool type; start filter for logs and traces

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 11:43:41 +05:30
srikanthccv
301addeff7 fix(telemetrymetadata): keep span-context key lookups lenient and return bool values
- the span context must keep returning attribute keys from the keys table:
  queries spell span attributes as span.<name> and resolve them through the
  same lookup; only the static fields are filtered by context now
- the fields/values handler dropped boolValues when assembling the response
- replace the ClickHouse-mock unit tests with integration tests at the HTTP
  boundary (tests/integration/tests/queriercommon/07_fields_keys_values.py)

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 11:43:41 +05:30
srikanthccv
ad6e16d103 feat(telemetrymetadata): bool values, start filter, and context-aware static keys
- fields/values returns boolValues for bool fields: when the caller asks for
  the bool type, when the name is a static bool field of the signal, or when
  the tag table holds bool rows for the key
- span and log values honor startUnixMilli against the tag tables
- static (intrinsic and calculated) keys honor fieldContext and fieldDataType
  and match case-insensitively; span and log contexts are served by the
  static fields instead of returning attribute keys or nothing
- isRoot/isEntryPoint are typed bool and meter keys carry a string data type
- skip the column evolution lookup when there are no keys to enrich

Assisted-by: Claude Fable 5.1
Claude-Session: https://claude.ai/code/session_01NNQrizqt5XQ1N2NNAcuAi8
2026-09-08 11:43:41 +05:30
40 changed files with 1096 additions and 1195 deletions

View File

@@ -397,7 +397,6 @@ identn:
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle apikey identN
enabled: true

View File

@@ -184,7 +184,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)
apiHandler.RegisterWebSocketPaths(r, am)
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
@@ -197,7 +196,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
})
handler := c.Handler(r)

View File

@@ -1,25 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
const getCustomFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props;
try {
const response = await axios.get(`/orgs/me/filters/${signal}`);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getCustomFilters;

View File

@@ -1,13 +0,0 @@
import axios from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> =>
axios.put(`/orgs/me/filters`, {
...props.data,
});
export default updateCustomFiltersAPI;

View File

@@ -34,6 +34,7 @@ export function mockFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
boolValues?: (boolean | null)[];
}): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
@@ -46,6 +47,7 @@ export function mockFieldsValuesAPI(response: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
boolValues: response.boolValues ?? [],
},
},
}),

View File

@@ -22,7 +22,10 @@ interface UseFieldValuesReturn {
isFetching: boolean;
}
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
export const DATA_SOURCE_TO_SIGNAL: Record<
DataSource,
TelemetrytypesSignalDTO
> = {
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
@@ -89,8 +92,12 @@ export function useFieldValues({
values.numberValues
?.filter((value): value is number => value !== null && value !== undefined)
.map((value) => value.toString()) || [];
const boolValues =
values.boolValues
?.filter((value): value is boolean => value !== null && value !== undefined)
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues];
return [...stringValues, ...numberValues, ...boolValues];
}, [data]);
return { relatedValues, allValues, isLoading, isFetching };

View File

@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
function SortableFilter({
filter,
@@ -25,13 +25,13 @@ function SortableFilter({
allowDrag,
allowRemove,
}: {
filter: FilterType;
onRemove: (filter: FilterType) => void;
filter: TelemetryFieldKey;
onRemove: (filter: TelemetryFieldKey) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: filter.key });
useSortable({ id: filter.key as string });
const style = {
transform: CSS.Transform.toString(transform),
@@ -46,14 +46,14 @@ function SortableFilter({
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={16} />}
{filter.key}
{filter.name}
</div>
{allowRemove && (
<Button
className="remove-filter-btn periscope-btn"
size="small"
onClick={(): void => {
onRemove(filter as FilterType);
onRemove(filter);
}}
>
Remove
@@ -69,8 +69,8 @@ function AddedFilters({
setAddedFilters,
}: {
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
@@ -90,12 +90,12 @@ function AddedFilters({
const filteredAddedFilters = useMemo(
() =>
addedFilters.filter((filter) =>
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
),
[addedFilters, inputValue],
);
const handleRemoveFilter = (filter: FilterType): void => {
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
};
@@ -116,7 +116,7 @@ function AddedFilters({
<div className="no-values-found">No values found</div>
) : (
<SortableContext
items={addedFilters.map((f) => f.key)}
items={addedFilters.map((f) => f.key as string)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>

View File

@@ -1,17 +1,17 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DATA_SOURCE_TO_SIGNAL } from 'components/QuickFilters/FilterRenderers/Checkbox/v2/useFieldValues';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { DataSource } from 'types/common/queryBuilder';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import {
FieldContext,
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
function OtherFiltersSkeleton(): JSX.Element {
return (
@@ -37,106 +37,48 @@ function OtherFilters({
}: {
signal: SignalType | undefined;
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isLogDataSource = useMemo(
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
[signal],
);
const isMeterDataSource = useMemo(
() => signal && signal === SignalType.METER_EXPLORER,
[signal],
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const { data, isFetching } = useGetFieldsKeys(
{
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
},
{ query: { enabled: !!signal } },
);
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
useGetAttributeSuggestions(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
filters: {} as TagFilter,
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isLogDataSource,
},
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
name: attr.name,
signal: attr.signal as TelemetryFieldKey['signal'],
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType as FieldDataType,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
}));
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
useGetAggregateKeys(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
},
const addedKeys = new Set(
addedFilters.map((filter) =>
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
),
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
useGetQueryKeySuggestions(
{
searchText: inputValue,
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
signalSource: 'meter',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isMeterDataSource,
},
);
const otherFilters = useMemo(() => {
let filterAttributes;
if (isLogDataSource) {
filterAttributes = suggestionsData?.payload?.attributes || [];
} else if (isMeterDataSource) {
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
fieldKeysData?.data?.data?.keys || {},
)?.flat();
filterAttributes = fieldKeys.map(
(attr) =>
({
key: attr.name,
dataType: attr.fieldDataType,
type: attr.fieldContext,
signal: attr.signal,
}) as BaseAutocompleteData,
);
} else {
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
}
return filterAttributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
);
}, [
suggestionsData,
aggregateKeysData,
addedFilters,
isLogDataSource,
fieldKeysData,
isMeterDataSource,
]);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
{
key: filter.key,
dataType: filter.dataType,
type: filter.type,
},
]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);
};
const renderFilters = (): React.ReactNode => {
const isLoading =
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
if (isLoading) {
if (isFetching) {
return <OtherFiltersSkeleton />;
}
if (!otherFilters?.length) {
@@ -145,11 +87,11 @@ function OtherFilters({
return otherFilters.map((filter) => (
<div key={filter.key} className="qf-filter-item other-filters-item">
<div className="qf-filter-key">{filter.key}</div>
<div className="qf-filter-key">{filter.name}</div>
<Button
className="add-filter-btn periscope-btn"
size="small"
onClick={(): void => handleAddFilter(filter as FilterType)}
onClick={(): void => handleAddFilter(filter)}
>
Add
</Button>

View File

@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
@@ -19,7 +18,7 @@ function QuickFiltersSettings({
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
}): JSX.Element {
const {
@@ -28,6 +27,7 @@ function QuickFiltersSettings({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
handleInputChange,
@@ -39,18 +39,6 @@ function QuickFiltersSettings({
signal,
});
const hasUnsavedChanges = useMemo(
() =>
// check if both arrays have the same length and same order of elements
!(
addedFilters.length === customFilters.length &&
addedFilters.every(
(filter, index) => filter.key === customFilters[index].key,
)
),
[addedFilters, customFilters],
);
return (
<>
<div className="qf-header">

View File

@@ -1,27 +1,31 @@
import { useCallback, useState } from 'react';
import { useMutation } from 'react-query';
import { useCallback, useMemo, useState } from 'react';
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
import logEvent from 'api/common/logEvent';
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
handleSettingsClose: () => void;
handleDiscardChanges: () => void;
handleSaveChanges: () => void;
hasUnsavedChanges: boolean;
isUpdatingCustomFilters: boolean;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
@@ -37,27 +41,43 @@ const useQuickFilterSettings = ({
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
() =>
customFilters.map((filter) => ({
...filter,
key: buildCompositeKey(
filter.name,
filter.fieldContext,
filter.fieldDataType,
),
})),
[customFilters],
);
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
normalizedCustomFilters,
);
const { notifications } = useNotifications();
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error: AxiosError) => {
notifications.error({
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
useUpdateQuickFilters({
mutation: {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
void logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error) => {
notifications.error({
message: error.message || SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
},
});
const debouncedUpdate = useDebouncedFn((value) => {
@@ -78,19 +98,32 @@ const useQuickFilterSettings = ({
}, [setIsSettingsOpen]);
const handleDiscardChanges = useCallback((): void => {
setAddedFilters(customFilters);
}, [customFilters, setAddedFilters]);
setAddedFilters(normalizedCustomFilters);
}, [normalizedCustomFilters, setAddedFilters]);
const hasUnsavedChanges = useMemo(
() =>
!(
addedFilters.length === normalizedCustomFilters.length &&
addedFilters.every(
(filter, index) => filter.key === normalizedCustomFilters[index].key,
)
),
[addedFilters, normalizedCustomFilters],
);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
pathParams: { source: signal },
data: {
// Send only the stored TelemetryFieldKey fields; the composite `key`
// is UI-only.
filters: addedFilters.map((filter) => ({
key: filter.key,
datatype: filter.dataType,
type: filter.type,
name: filter.name,
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
})),
signal,
},
});
}
@@ -102,6 +135,7 @@ const useQuickFilterSettings = ({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
setInputValue,

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
@@ -13,7 +11,7 @@ interface UseFilterConfigProps {
}
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
refetchCustomFilters: () => void;
@@ -25,17 +23,16 @@ const useFilterConfig = ({
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
isFetching: isCustomFiltersLoading,
data: customFilters = [],
data,
refetch,
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
} = useGetQuickFilters(
{ source: signal ?? '' },
{ query: { enabled: !!signal } },
);
const customFilters = useMemo<TelemetryFieldKey[]>(
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
[data],
);
const isDynamicFilters = useMemo(

View File

@@ -11,7 +11,7 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import '@testing-library/jest-dom';
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -338,6 +338,63 @@ describe('Quick Filters with custom filters', () => {
);
});
it('keeps same-name fields with different context as distinct entries', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: {
level: [
{
name: 'level',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'level',
fieldContext: 'span',
fieldDataType: 'string',
signal: 'logs',
},
],
},
},
}),
),
),
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
// Both `level` variants are shown despite sharing a name.
await waitFor(() =>
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
);
// Adding one variant removes only that one; the other stays.
const firstLevel = within(otherSection).getAllByText('level')[0];
const addButton = firstLevel.parentElement?.querySelector('button');
await user.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
});
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -456,12 +513,10 @@ describe('Quick Filters with custom filters', () => {
});
const requestBody = putHandler.mock.calls[0][0];
expect(requestBody.filters).toStrictEqual(
expect.arrayContaining([
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
]),
expect(requestBody.filters).not.toContainEqual(
expect.objectContaining({ name: FILTER_OS_DESCRIPTION }),
);
expect(requestBody.signal).toBe(SIGNAL);
expect(requestBody.filters).toHaveLength(10);
});
it('should render duration slider for duration_nono filter', async () => {
@@ -612,9 +667,9 @@ describe('Quick Filters refetch behavior', () => {
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
name: 'new.custom.filter',
fieldDataType: 'string',
fieldContext: 'resource',
} as const,
],
},

View File

@@ -1,17 +1,33 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
const FILTER_TITLE_MAP: Record<string, string> = {
duration_nano: 'Duration',
hasError: 'Has Error (Status)',
has_error: 'Has Error (Status)',
};
const FILTER_TYPE_MAP: Record<string, FiltersType> = {
duration_nano: FiltersType.DURATION,
};
// The map below exists only for the old v3 attribute-values fetch
// (useCheckboxFilterValues), the sole reader of attributeKey.dataType/type.
// Once the values fetch moves to fields/values, remove this and reduce
// attributeKey to { id, key }.
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
[TelemetrytypesFieldContextDTO.resource]: 'resource',
};
const mapFieldContext = (fieldContext?: string): string =>
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
const getFilterName = (str: string): string => {
if (FILTER_TITLE_MAP[str]) {
return FILTER_TITLE_MAP[str];
@@ -26,16 +42,16 @@ const getFilterName = (str: string): string => {
.join(' ');
};
const getFilterType = (att: FilterType): FiltersType => {
if (FILTER_TYPE_MAP[att.key]) {
return FILTER_TYPE_MAP[att.key];
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
if (FILTER_TYPE_MAP[att.name]) {
return FILTER_TYPE_MAP[att.name];
}
return FiltersType.CHECKBOX;
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: FilterType[],
customFilters?: TelemetryFieldKey[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length || !signal) {
@@ -46,13 +62,13 @@ export const getFilterConfig = (
(att, index) =>
({
type: getFilterType(att),
title: getFilterName(att.key),
title: getFilterName(att.name),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.key,
key: att.key,
dataType: att.dataType,
type: att.type,
id: att.name,
key: att.name,
dataType: fieldDataTypeToDataType(att.fieldDataType),
type: mapFieldContext(att.fieldContext),
},
defaultOpen: index < 2,
}) as IQuickFiltersConfig,

View File

@@ -108,7 +108,6 @@ function LogsExplorerViewsContainer({
const [page, setPage] = useState<number>(1);
const [logs, setLogs] = useState<ILog[]>([]);
const [requestData, setRequestData] = useState<Query | null>(null);
const [queryId, setQueryId] = useState<string>(v4());
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
@@ -180,12 +179,7 @@ function LogsExplorerViewsContainer({
},
undefined,
listQueryKeyRef,
{
...(!isEmpty(queryId) &&
selectedPanelType !== PANEL_TYPES.LIST && {
'X-SIGNOZ-QUERY-ID': queryId,
}),
},
undefined,
// custom selected time interval to prevent recalculating the start and end timestamps before fetching next pages
'custom',
);
@@ -250,10 +244,6 @@ function LogsExplorerViewsContainer({
setRequestData(newRequestData);
}, [isLimit, logs, listQuery, pageSize, stagedQuery, getRequestData, page]);
useEffect(() => {
setQueryId(v4());
}, [data]);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {

View File

@@ -4,114 +4,85 @@ export const quickFiltersListResponse = {
signal: 'logs',
filters: [
{
key: 'os.description',
dataType: 'string',
type: 'resource',
name: 'os.description',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.name',
dataType: 'string',
type: 'resource',
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'duration_nano',
dataType: 'float64',
type: 'tag',
name: 'duration_nano',
fieldDataType: 'float64',
fieldContext: 'attribute',
},
{
key: 'quantity',
dataType: 'float64',
type: 'tag',
name: 'quantity',
fieldDataType: 'float64',
fieldContext: 'attribute',
},
{
key: 'body',
dataType: 'string',
type: '',
name: 'body',
fieldDataType: 'string',
fieldContext: '',
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
name: 'deployment.environment',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
name: 'service.namespace',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
name: 'k8s.namespace.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
name: 'service.instance.id',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
name: 'k8s.pod.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'process.owner',
dataType: 'string',
type: 'resource',
name: 'process.owner',
fieldDataType: 'string',
fieldContext: 'resource',
},
],
},
};
const otherFilterName = (name: string): { [k: string]: unknown[] } => ({
[name]: [
{ name, fieldContext: 'resource', fieldDataType: 'string', signal: 'logs' },
],
});
export const otherFiltersResponse = {
status: 'success',
data: {
attributes: [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.deployment.name',
dataType: 'string',
type: 'resource',
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.pod.uid',
dataType: 'string',
type: 'resource',
},
{
key: 'os.description',
dataType: 'string',
type: 'resource',
},
],
complete: true,
keys: {
...otherFilterName('service.name'),
...otherFilterName('k8s.deployment.name'),
...otherFilterName('deployment.environment'),
...otherFilterName('service.namespace'),
...otherFilterName('k8s.namespace.name'),
...otherFilterName('service.instance.id'),
...otherFilterName('k8s.pod.name'),
...otherFilterName('k8s.pod.uid'),
...otherFilterName('os.description'),
},
},
};

View File

@@ -504,7 +504,7 @@ jest.mock('hooks/useHandleExplorerTabChange', () => ({
let capturedPayload: QueryRangePayloadV5;
describe('TracesExplorer -', () => {
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/traces`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/traces`;
const setupServer = (): void => {
server.use(

View File

@@ -1,14 +0,0 @@
export interface Filter {
key: string;
dataType: string;
type: string;
}
export interface Props {
signal: string;
}
export type PayloadProps = {
filters: Filter[];
signal: string;
};

View File

@@ -1,14 +0,0 @@
import { SignalType } from 'components/QuickFilters/types';
interface FilterType {
key: string;
datatype: string;
type: string;
}
export interface UpdateCustomFiltersProps {
data: {
filters: FilterType[];
signal: SignalType;
};
}

2
go.mod
View File

@@ -31,7 +31,6 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/huandu/go-sqlbuilder v1.39.1
github.com/jackc/pgx/v5 v5.9.2
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
@@ -134,6 +133,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/huandu/go-clone v1.7.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect

View File

@@ -45,7 +45,7 @@ func newConfig() factory.Config {
return &Config{
Tokenizer: TokenizerConfig{
Enabled: true,
Headers: []string{"Authorization", "Sec-WebSocket-Protocol"},
Headers: []string{"Authorization"},
},
APIKeyConfig: APIKeyConfig{
Enabled: true,

View File

@@ -82,6 +82,7 @@ func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Reques
values := &telemetrytypes.TelemetryFieldValues{
StringValues: allValues.StringValues,
BoolValues: allValues.BoolValues,
NumberValues: allValues.NumberValues,
RelatedValues: relatedValues,
}

View File

@@ -1,243 +0,0 @@
package queryprogress
import (
"fmt"
"log/slog"
"sync"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/google/uuid"
"golang.org/x/exp/maps"
)
// tracks progress and manages subscriptions for all queries
type inMemoryQueryProgressTracker struct {
logger *slog.Logger
queries map[string]*queryTracker
lock sync.RWMutex
}
func (tracker *inMemoryQueryProgressTracker) ReportQueryStarted(
queryId string,
) (postQueryCleanup func(), apiErr *model.ApiError) {
tracker.lock.Lock()
defer tracker.lock.Unlock()
_, exists := tracker.queries[queryId]
if exists {
return nil, model.BadRequest(fmt.Errorf(
"query %s already started", queryId,
))
}
tracker.queries[queryId] = newQueryTracker(tracker.logger, queryId)
return func() {
tracker.onQueryFinished(queryId)
}, nil
}
func (tracker *inMemoryQueryProgressTracker) ReportQueryProgress(
queryId string, chProgress *clickhouse.Progress,
) *model.ApiError {
queryTracker, err := tracker.getQueryTracker(queryId)
if err != nil {
return err
}
queryTracker.handleProgressUpdate(chProgress)
return nil
}
func (tracker *inMemoryQueryProgressTracker) SubscribeToQueryProgress(
queryId string,
) (<-chan model.QueryProgress, func(), *model.ApiError) {
queryTracker, err := tracker.getQueryTracker(queryId)
if err != nil {
return nil, nil, err
}
return queryTracker.subscribe()
}
func (tracker *inMemoryQueryProgressTracker) onQueryFinished(
queryId string,
) {
tracker.lock.Lock()
queryTracker := tracker.queries[queryId]
if queryTracker != nil {
delete(tracker.queries, queryId)
}
tracker.lock.Unlock()
if queryTracker != nil {
queryTracker.onFinished()
}
}
func (tracker *inMemoryQueryProgressTracker) getQueryTracker(
queryId string,
) (*queryTracker, *model.ApiError) {
tracker.lock.RLock()
defer tracker.lock.RUnlock()
queryTracker := tracker.queries[queryId]
if queryTracker == nil {
return nil, model.NotFoundError(fmt.Errorf(
"query %s doesn't exist", queryId,
))
}
return queryTracker, nil
}
// Tracks progress and manages subscriptions for a single query
type queryTracker struct {
logger *slog.Logger
queryId string
isFinished bool
progress *model.QueryProgress
subscriptions map[string]*queryProgressSubscription
lock sync.Mutex
}
func newQueryTracker(logger *slog.Logger, queryId string) *queryTracker {
return &queryTracker{
logger: logger,
queryId: queryId,
subscriptions: map[string]*queryProgressSubscription{},
}
}
func (qt *queryTracker) handleProgressUpdate(p *clickhouse.Progress) {
qt.lock.Lock()
defer qt.lock.Unlock()
if qt.isFinished {
qt.logger.Warn("received clickhouse progress update for finished query", "queryId", qt.queryId, "progress", p)
return
}
if qt.progress == nil {
// This is the first update
qt.progress = &model.QueryProgress{}
}
updateQueryProgress(qt.progress, p)
// broadcast latest state to all subscribers.
for _, sub := range maps.Values(qt.subscriptions) {
sub.send(*qt.progress)
}
}
func (qt *queryTracker) subscribe() (
<-chan model.QueryProgress, func(), *model.ApiError,
) {
qt.lock.Lock()
defer qt.lock.Unlock()
if qt.isFinished {
return nil, nil, model.NotFoundError(fmt.Errorf(
"query %s already finished", qt.queryId,
))
}
subscriberId := uuid.NewString()
subscription := newQueryProgressSubscription(qt.logger)
qt.subscriptions[subscriberId] = subscription
if qt.progress != nil {
subscription.send(*qt.progress)
}
return subscription.ch, func() {
qt.unsubscribe(subscriberId)
}, nil
}
func (qt *queryTracker) unsubscribe(subscriberId string) {
qt.lock.Lock()
defer qt.lock.Unlock()
if qt.isFinished {
qt.logger.Debug("received unsubscribe request after query finished", "subscriber", subscriberId, "queryId", qt.queryId)
return
}
subscription := qt.subscriptions[subscriberId]
if subscription != nil {
subscription.close()
delete(qt.subscriptions, subscriberId)
}
}
func (qt *queryTracker) onFinished() {
qt.lock.Lock()
defer qt.lock.Unlock()
if qt.isFinished {
qt.logger.Warn("receiver query finish report after query finished", "queryId", qt.queryId)
return
}
for subId, sub := range qt.subscriptions {
sub.close()
delete(qt.subscriptions, subId)
}
qt.isFinished = true
}
type queryProgressSubscription struct {
logger *slog.Logger
ch chan model.QueryProgress
isClosed bool
lock sync.Mutex
}
func newQueryProgressSubscription(logger *slog.Logger) *queryProgressSubscription {
ch := make(chan model.QueryProgress, 1000)
return &queryProgressSubscription{
logger: logger,
ch: ch,
}
}
// Must not block or panic in any scenario
func (ch *queryProgressSubscription) send(progress model.QueryProgress) {
ch.lock.Lock()
defer ch.lock.Unlock()
if ch.isClosed {
ch.logger.Error("can't send query progress: channel already closed.", "progress", progress)
return
}
// subscription channels are expected to have big enough buffers to ensure
// blocking while sending doesn't happen in the happy path
select {
case ch.ch <- progress:
ch.logger.Debug("published query progress", "progress", progress)
default:
ch.logger.Error("couldn't publish query progress. dropping update.", "progress", progress)
}
}
func (ch *queryProgressSubscription) close() {
ch.lock.Lock()
defer ch.lock.Unlock()
if !ch.isClosed {
close(ch.ch)
ch.isClosed = true
}
}
func updateQueryProgress(qp *model.QueryProgress, chProgress *clickhouse.Progress) {
qp.ReadRows += chProgress.Rows
qp.ReadBytes += chProgress.Bytes
qp.ElapsedMs += uint64(chProgress.Elapsed.Milliseconds())
}

View File

@@ -1,33 +0,0 @@
package queryprogress
import (
"log/slog"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/query-service/model"
)
type QueryProgressTracker interface {
// Tells the tracker that query with id `queryId` has started.
// Progress can only be reported for and tracked for a query that is in progress.
// Returns a cleanup function that must be called after the query finishes.
ReportQueryStarted(queryId string) (postQueryCleanup func(), apiErr *model.ApiError)
// Report progress stats received from clickhouse for `queryId`
ReportQueryProgress(queryId string, chProgress *clickhouse.Progress) *model.ApiError
// Subscribe to progress updates for `queryId`
// The returned channel will produce `QueryProgress` instances representing
// the latest state of query progress stats. Also returns a function that
// can be called to unsubscribe before the query finishes, if needed.
SubscribeToQueryProgress(queryId string) (ch <-chan model.QueryProgress, unsubscribe func(), apiErr *model.ApiError)
}
func NewQueryProgressTracker(logger *slog.Logger) QueryProgressTracker {
// InMemory tracker is useful only for single replica query service setups.
// Multi replica setups must use a centralized store for tracking and subscribing to query progress
return &inMemoryQueryProgressTracker{
logger: logger,
queries: map[string]*queryTracker{},
}
}

View File

@@ -1,102 +0,0 @@
package queryprogress
import (
"log/slog"
"testing"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/stretchr/testify/require"
)
func TestQueryProgressTracking(t *testing.T) {
require := require.New(t)
tracker := NewQueryProgressTracker(slog.Default())
testQueryId := "test-query"
testProgress := &clickhouse.Progress{}
err := tracker.ReportQueryProgress(testQueryId, testProgress)
require.NotNil(err, "shouldn't be able to report query progress before query has been started")
require.Equal(err.Type(), model.ErrorNotFound)
ch, unsubscribe, err := tracker.SubscribeToQueryProgress(testQueryId)
require.NotNil(err, "shouldn't be able to subscribe for progress updates before query has been started")
require.Equal(err.Type(), model.ErrorNotFound)
require.Nil(ch)
require.Nil(unsubscribe)
reportQueryFinished, err := tracker.ReportQueryStarted(testQueryId)
require.Nil(err, "should be able to report start of a query to be tracked")
testProgress1 := &clickhouse.Progress{
Rows: 10,
Bytes: 20,
TotalRows: 100,
Elapsed: 20 * time.Millisecond,
}
err = tracker.ReportQueryProgress(testQueryId, testProgress1)
require.Nil(err, "should be able to report progress after query has started")
ch, unsubscribe, err = tracker.SubscribeToQueryProgress(testQueryId)
require.Nil(err, "should be able to subscribe to query progress updates after query started")
require.NotNil(ch)
require.NotNil(unsubscribe)
expectedProgress := model.QueryProgress{}
updateQueryProgress(&expectedProgress, testProgress1)
require.Equal(expectedProgress.ReadRows, testProgress1.Rows)
select {
case qp := <-ch:
require.Equal(qp, expectedProgress)
default:
require.Fail("should receive latest query progress state immediately after subscription")
}
select {
case _ = <-ch:
require.Fail("should have had only one pending update at this point")
default:
}
testProgress2 := &clickhouse.Progress{
Rows: 20,
Bytes: 40,
TotalRows: 100,
Elapsed: 40 * time.Millisecond,
}
err = tracker.ReportQueryProgress(testQueryId, testProgress2)
require.Nil(err, "should be able to report progress multiple times while query is in progress")
updateQueryProgress(&expectedProgress, testProgress2)
select {
case qp := <-ch:
require.Equal(qp, expectedProgress)
default:
require.Fail("should receive updates whenever new progress updates get reported to tracker")
}
select {
case _ = <-ch:
require.Fail("should have had only one pending update at this point")
default:
}
reportQueryFinished()
select {
case _, isSubscriptionChannelOpen := <-ch:
require.False(isSubscriptionChannelOpen, "subscription channels should get closed after query finishes")
default:
require.Fail("subscription channels should get closed after query finishes")
}
err = tracker.ReportQueryProgress(testQueryId, testProgress)
require.NotNil(err, "shouldn't be able to report query progress after query has finished")
require.Equal(err.Type(), model.ErrorNotFound)
ch, unsubscribe, err = tracker.SubscribeToQueryProgress(testQueryId)
require.NotNil(err, "shouldn't be able to subscribe for progress updates after query has finished")
require.Equal(err.Type(), model.ErrorNotFound)
require.Nil(ch)
require.Nil(unsubscribe)
}

View File

@@ -44,7 +44,6 @@ import (
"log/slog"
queryprogress "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader/query_progress"
"github.com/SigNoz/signoz/pkg/query-service/app/resource"
"github.com/SigNoz/signoz/pkg/query-service/app/services"
"github.com/SigNoz/signoz/pkg/query-service/app/traces/smart"
@@ -145,7 +144,6 @@ type ClickHouseReader struct {
logsResourceKeys string
logsTagAttributeTableV2 string
logger *slog.Logger
queryProgressTracker queryprogress.QueryProgressTracker
logsTableV2 string
logsLocalTableV2 string
@@ -214,7 +212,6 @@ func NewReader(
logsTagAttributeTableV2: options.primary.LogsTagAttributeTableV2,
liveTailRefreshSeconds: options.primary.LiveTailRefreshSeconds,
cluster: cluster,
queryProgressTracker: queryprogress.NewQueryProgressTracker(logger),
logsTableV2: options.primary.LogsTableV2,
logsLocalTableV2: options.primary.LogsLocalTableV2,
logsResourceTableV2: options.primary.LogsResourceTableV2,
@@ -4024,27 +4021,6 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetTimeSeriesResultV3",
})
// Hook up query progress reporting if requested.
queryId := ctx.Value("queryId")
if queryId != nil {
qid, ok := queryId.(string)
if !ok {
r.logger.Error("GetTimeSeriesResultV3: queryId in ctx not a string as expected", "queryId", queryId)
} else {
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(
func(p *clickhouse.Progress) {
go func() {
err := r.queryProgressTracker.ReportQueryProgress(qid, p)
if err != nil {
r.logger.Error("Couldn't report query progress", "queryId", qid, errorsV2.Attr(err))
}
}()
},
))
}
}
rows, err := r.db.Query(ctx, query)
if err != nil {
@@ -5005,18 +4981,6 @@ func (r *ClickHouseReader) GetMinAndMaxTimestampForTraceID(ctx context.Context,
return minTime.UnixNano(), maxTime.UnixNano(), nil
}
func (r *ClickHouseReader) ReportQueryStartForProgressTracking(
queryId string,
) (func(), *model.ApiError) {
return r.queryProgressTracker.ReportQueryStarted(queryId)
}
func (r *ClickHouseReader) SubscribeToQueryProgress(
queryId string,
) (<-chan model.QueryProgress, func(), *model.ApiError) {
return r.queryProgressTracker.SubscribeToQueryProgress(queryId)
}
func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),

View File

@@ -36,7 +36,6 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
jsoniter "github.com/json-iterator/go"
_ "modernc.org/sqlite"
@@ -115,9 +114,6 @@ type APIHandler struct {
// is registers.
SetupCompleted bool
// Websocket connection upgrader
Upgrader *websocket.Upgrader
QueryParserAPI *queryparser.API
Signoz *signoz.SigNoz
@@ -207,12 +203,6 @@ func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, erro
aH.SetupCompleted = true
}
aH.Upgrader = &websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
return aH, nil
}
@@ -354,18 +344,10 @@ func (aH *APIHandler) RegisterQueryRangeV3Routes(router *mux.Router, am *middlew
subRouter.HandleFunc("/filter_suggestions", am.ViewAccess(aH.getQueryBuilderSuggestions)).Methods(http.MethodGet)
// TODO(Raj): Remove this handler after /ws based path has been completely rolled out.
subRouter.HandleFunc("/query_progress", am.ViewAccess(aH.GetQueryProgressUpdates)).Methods(http.MethodGet)
// live logs
subRouter.HandleFunc("/logs/livetail", am.ViewAccess(aH.Signoz.Handlers.QuerierHandler.QueryRawStream)).Methods(http.MethodGet)
}
func (aH *APIHandler) RegisterWebSocketPaths(router *mux.Router, am *middleware.AuthZ) {
subRouter := router.PathPrefix("/ws").Subrouter()
subRouter.HandleFunc("/query_progress", am.ViewAccess(aH.GetQueryProgressUpdates)).Methods(http.MethodGet)
}
func (aH *APIHandler) RegisterQueryRangeV4Routes(router *mux.Router, am *middleware.AuthZ) {
subRouter := router.PathPrefix("/api/v4").Subrouter()
subRouter.HandleFunc("/query_range", am.ViewAccess(aH.QueryRangeV4)).Methods(http.MethodPost)
@@ -3545,27 +3527,6 @@ func (aH *APIHandler) queryRangeV3(ctx context.Context, queryRangeParams *v3.Que
}
}
// Hook up query progress tracking if requested
queryIdHeader := r.Header.Get("X-SIGNOZ-QUERY-ID")
if len(queryIdHeader) > 0 {
onQueryFinished, apiErr := aH.reader.ReportQueryStartForProgressTracking(queryIdHeader)
if apiErr != nil {
aH.logger.ErrorContext(ctx, "failed to report query start for progress tracking",
"query_id", queryIdHeader, errors.Attr(apiErr),
)
} else {
// Adding queryId to the context signals clickhouse queries to report progress
//lint:ignore SA1029 ignore for now
ctx = context.WithValue(ctx, "queryId", queryIdHeader)
defer func() {
go onQueryFinished()
}()
}
}
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "app",
instrumentationtypes.CodeFunctionName: "QueryRange",
@@ -3747,73 +3708,6 @@ func (aH *APIHandler) QueryRangeV3(w http.ResponseWriter, r *http.Request) {
aH.queryRangeV3(r.Context(), queryRangeParams, w, r)
}
func (aH *APIHandler) GetQueryProgressUpdates(w http.ResponseWriter, r *http.Request) {
// Upgrade connection to websocket, sending back the requested protocol
// value for sec-websocket-protocol
//
// Since js websocket API doesn't allow setting headers, this header is often
// used for passing auth tokens. As per websocket spec the connection will only
// succeed if the requested `Sec-Websocket-Protocol` is sent back as a header
// in the upgrade response (signifying that the protocol is supported by the server).
upgradeResponseHeaders := http.Header{}
requestedProtocol := r.Header.Get("Sec-WebSocket-Protocol")
if len(requestedProtocol) > 0 {
upgradeResponseHeaders.Add("Sec-WebSocket-Protocol", requestedProtocol)
}
c, err := aH.Upgrader.Upgrade(w, r, upgradeResponseHeaders)
if err != nil {
RespondError(w, model.InternalError(fmt.Errorf(
"couldn't upgrade connection: %w", err,
)), nil)
return
}
defer c.Close()
// Websocket upgrade complete. Subscribe to query progress and send updates to client
//
// Note: we handle any subscription problems (queryId query param missing or query already complete etc)
// after the websocket connection upgrade by closing the channel.
// The other option would be to handle the errors before websocket upgrade by sending an
// error response instead of the upgrade response, but that leads to a generic websocket
// connection failure on the client.
queryId := r.URL.Query().Get("q")
progressCh, unsubscribe, apiErr := aH.reader.SubscribeToQueryProgress(queryId)
if apiErr != nil {
// Shouldn't happen unless query progress requested after query finished
aH.logger.WarnContext(r.Context(), "failed to subscribe to query progress",
"query_id", queryId, errors.Attr(apiErr),
)
return
}
defer func() { go unsubscribe() }()
for queryProgress := range progressCh {
msg, err := json.Marshal(queryProgress)
if err != nil {
aH.logger.ErrorContext(r.Context(), "failed to serialize progress message",
"query_id", queryId, "progress", queryProgress, errors.Attr(err),
)
continue
}
err = c.WriteMessage(websocket.TextMessage, msg)
if err != nil {
aH.logger.ErrorContext(r.Context(), "failed to write progress message to websocket",
"query_id", queryId, "msg", string(msg), errors.Attr(err),
)
break
} else {
aH.logger.DebugContext(r.Context(), "wrote progress message to websocket",
"query_id", queryId, "msg", string(msg),
)
}
}
}
func (aH *APIHandler) getMetricMetadata(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {

View File

@@ -164,7 +164,6 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
api.RegisterLogsRoutes(r, am)
api.RegisterIntegrationRoutes(r, am)
api.RegisterQueryRangeV3Routes(r, am)
api.RegisterWebSocketPaths(r, am)
api.RegisterQueryRangeV4Routes(r, am)
api.RegisterMessagingQueuesRoutes(r, am)
api.RegisterThirdPartyApiRoutes(r, am)
@@ -178,7 +177,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
})
handler := c.Handler(r)

View File

@@ -91,10 +91,6 @@ type Reader interface {
GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error)
// Query Progress tracking helpers.
ReportQueryStartForProgressTracking(queryId string) (reportQueryFinished func(), apiErr *model.ApiError)
SubscribeToQueryProgress(queryId string) (<-chan model.QueryProgress, func(), *model.ApiError)
//trace
GetTraceFields(ctx context.Context) (*model.GetFieldsResponse, *model.ApiError)
UpdateTraceField(ctx context.Context, field *model.UpdateField) *model.ApiError

View File

@@ -7,14 +7,6 @@ import (
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
)
type QueryProgress struct {
ReadRows uint64 `json:"read_rows"`
ReadBytes uint64 `json:"read_bytes"`
ElapsedMs uint64 `json:"elapsed_ms"`
}
func GetLogFieldsV3(ctx context.Context, queryRangeParams *v3.QueryRangeParamsV3, fields *GetFieldsResponse) map[string]v3.AttributeKey {
data := map[string]v3.AttributeKey{}
for _, query := range queryRangeParams.CompositeQuery.BuilderQueries {

View File

@@ -253,6 +253,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddQuickFilterTuplesFactory(sqlstore),
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
)
}

View File

@@ -0,0 +1,161 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type quickFilterSourceRow struct {
bun.BaseModel `bun:"table:quick_filter"`
ID string `bun:"id,pk"`
Source string `bun:"source"`
Filter string `bun:"filter"`
}
type quickFilterStaticField struct {
name string
fieldContext string
fieldDataType string
}
// quickFilterSpanFields are the span-level fields the fields API serves with
// the span context, keyed by every name a stored filter may carry for them.
var quickFilterSpanFields = func() map[string]quickFilterStaticField {
fields := map[string]quickFilterStaticField{}
for name, dataType := range map[string]string{
"trace_id": "string", "span_id": "string", "trace_state": "string", "parent_span_id": "string",
"flags": "number", "name": "string", "kind": "number", "kind_string": "string",
"duration_nano": "number", "status_code": "number", "status_message": "string", "status_code_string": "string",
"response_status_code": "string", "external_http_url": "string", "http_url": "string",
"external_http_method": "string", "http_method": "string", "http_host": "string",
"db_name": "string", "db_operation": "string", "has_error": "bool", "is_remote": "string",
} {
fields[name] = quickFilterStaticField{name: name, fieldContext: "span", fieldDataType: dataType}
}
for deprecated, current := range map[string]string{
"responseStatusCode": "response_status_code", "externalHttpUrl": "external_http_url", "httpUrl": "http_url",
"externalHttpMethod": "external_http_method", "httpMethod": "http_method", "httpHost": "http_host",
"dbName": "db_name", "dbOperation": "db_operation", "hasError": "has_error", "isRemote": "is_remote",
} {
fields[deprecated] = fields[current]
}
return fields
}()
// quickFilterLogFields are the log-level fields the fields API serves with
// the log context.
var quickFilterLogFields = map[string]quickFilterStaticField{
"body": {name: "body", fieldContext: "log", fieldDataType: "string"},
"severity_text": {name: "severity_text", fieldContext: "log", fieldDataType: "string"},
"severity_number": {name: "severity_number", fieldContext: "log", fieldDataType: "number"},
"trace_id": {name: "trace_id", fieldContext: "log", fieldDataType: "string"},
"span_id": {name: "span_id", fieldContext: "log", fieldDataType: "string"},
"trace_flags": {name: "trace_flags", fieldContext: "log", fieldDataType: "number"},
}
type normalizeQuickFilterFields struct {
settings factory.ProviderSettings
}
func NewNormalizeQuickFilterFieldsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("normalize_quick_filter_fields"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &normalizeQuickFilterFields{settings: ps}, nil
})
}
func (migration *normalizeQuickFilterFields) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *normalizeQuickFilterFields) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*quickFilterSourceRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var migrated, skipped int
for _, row := range rows {
normalized, changed, ok := normalizeQuickFilterEntries(row.Source, row.Filter)
if !ok {
migration.settings.Logger.WarnContext(ctx, "quick filter could not be parsed, leaving it untouched", slog.String("quick_filter_id", row.ID), slog.String("raw_filter", row.Filter))
skipped++
continue
}
if !changed {
continue
}
migrated++
if _, err := tx.NewUpdate().Model((*quickFilterSourceRow)(nil)).Set("filter = ?", normalized).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "normalized quick filter static fields", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
return tx.Commit()
}
func (migration *normalizeQuickFilterFields) Down(context.Context, *bun.DB) error {
return nil
}
// normalizeQuickFilterEntries rewrites the static fields of a stored filter
// list to the name, context and data type the fields API serves them with:
// span fields for the trace-based sources, log fields for logs, whatever
// context the legacy seeds gave them. Other keys are left as they are;
// ok=false means unparseable.
func normalizeQuickFilterEntries(source string, filter string) (normalized string, changed bool, ok bool) {
var staticFields map[string]quickFilterStaticField
switch source {
case "traces", "api_monitoring", "exceptions", "ai_observability":
staticFields = quickFilterSpanFields
case "logs":
staticFields = quickFilterLogFields
default:
return "", false, true
}
var entries []telemetryFieldKeyOutput
if err := json.Unmarshal([]byte(filter), &entries); err != nil {
return "", false, false
}
for i, entry := range entries {
field, static := staticFields[entry.Name]
if !static {
continue
}
if entry.Name == field.name && entry.FieldContext == field.fieldContext && entry.FieldDataType == field.fieldDataType {
continue
}
entries[i].Name = field.name
entries[i].FieldContext = field.fieldContext
entries[i].FieldDataType = field.fieldDataType
changed = true
}
if !changed {
return "", false, true
}
normalizedJSON, err := marshalUnescaped(entries)
if err != nil {
return "", false, false
}
return string(normalizedJSON), true, true
}

View File

@@ -0,0 +1,61 @@
package telemetrymetadata
import (
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// boolFieldValues is the suggestion set for a bool field, optionally narrowed
// by the search text.
func boolFieldValues(searchText string) *telemetrytypes.TelemetryFieldValues {
values := &telemetrytypes.TelemetryFieldValues{}
needle := strings.ToLower(searchText)
for _, v := range []bool{true, false} {
if needle == "" || strings.Contains(strconv.FormatBool(v), needle) {
values.BoolValues = append(values.BoolValues, v)
}
}
return values
}
// spanScopeFieldValues is the suggestion set for a span scope selector
// (isRoot, isEntryPoint), which only filters with true. ok is false for any
// other name.
func spanScopeFieldValues(name, searchText string) (*telemetrytypes.TelemetryFieldValues, bool) {
for scopeName := range tracestelemetryschema.SpanScopeFields {
if !strings.EqualFold(scopeName, name) {
continue
}
values := &telemetrytypes.TelemetryFieldValues{}
if needle := strings.ToLower(searchText); needle == "" || strings.Contains("true", needle) {
values.BoolValues = []bool{true}
}
return values, true
}
return nil, false
}
// isKnownBoolField is true when the caller asked for the bool data type, or
// when the name is one of the signal's static bool fields and the requested
// context does not rule that static field out.
func isKnownBoolField(selector *telemetrytypes.FieldValueSelector, staticFields ...map[string]telemetrytypes.TelemetryFieldKey) bool {
if selector.FieldDataType == telemetrytypes.FieldDataTypeBool {
return true
}
if selector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
return false
}
for _, fields := range staticFields {
field, ok := fields[selector.Name]
if !ok || field.FieldDataType != telemetrytypes.FieldDataTypeBool {
continue
}
if selector.FieldContext == telemetrytypes.FieldContextUnspecified || selector.FieldContext == field.FieldContext {
return true
}
}
return false
}

View File

@@ -187,8 +187,6 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
).From(t.tracesDBName + "." + t.spanAttributesKeysTblName)
var limit int
searchTexts := []string{}
conds := []string{}
for _, fieldKeySelector := range fieldKeySelectors {
@@ -208,14 +206,12 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
fieldKeyConds = append(fieldKeyConds, sb.ILike("tagKey", "%"+escapeForLike(fieldKeySelector.Name)+"%"))
}
searchTexts = append(searchTexts, fieldKeySelector.Name)
// now look at the field context
// we don't write most of intrinsic fields to keys table
// for this reason we don't want to apply tagType if the field context
// is not attribute or resource attribute
if fieldKeySelector.FieldContext != telemetrytypes.FieldContextUnspecified &&
(fieldKeySelector.FieldContext == telemetrytypes.FieldContextAttribute ||
fieldKeySelector.FieldContext == telemetrytypes.FieldContextResource) {
// is not attribute, resource attribute or scope
switch fieldKeySelector.FieldContext {
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource, telemetrytypes.FieldContextScope:
fieldKeyConds = append(fieldKeyConds, sb.E("tagType", fieldKeySelector.FieldContext.TagType()))
}
@@ -288,41 +284,20 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
// hit the limit? (only counting DB results)
complete := rowCount <= limit
staticKeys := []string{"isRoot", "isEntryPoint"}
staticKeys = append(staticKeys, maps.Keys(tracestelemetryschema.IntrinsicFields)...)
staticKeys = append(staticKeys, maps.Keys(tracestelemetryschema.CalculatedFields)...)
// Add the matching static fields: the span scope selectors, the intrinsic
// columns and the calculated columns. These don't count towards the limit
staticFields := maps.Values(tracestelemetryschema.SpanScopeFields)
staticFields = append(staticFields, maps.Values(tracestelemetryschema.IntrinsicFields)...)
staticFields = append(staticFields, maps.Values(tracestelemetryschema.CalculatedFields)...)
// Add matching intrinsic and matching calculated fields
// These don't count towards the limit
for _, key := range staticKeys {
found := false
for _, v := range searchTexts {
if v == "" || strings.Contains(key, v) {
found = true
break
}
for _, field := range staticFields {
if !staticFieldMatchesAny(field, fieldKeySelectors) {
continue
}
if found {
if field, exists := tracestelemetryschema.IntrinsicFields[key]; exists {
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
keys = append(keys, &field)
}
continue
}
if field, exists := tracestelemetryschema.CalculatedFields[key]; exists {
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
keys = append(keys, &field)
}
continue
}
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
Name: key,
FieldContext: telemetrytypes.FieldContextSpan,
Signal: telemetrytypes.SignalTraces,
})
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
continue
}
keys = append(keys, &field)
}
if err = t.updateColumnEvolutionMetadataForKeys(ctx, keys); err != nil {
@@ -542,12 +517,6 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, orgID valuer.UUID,
allArgs = append(allArgs, args...)
}
if len(queries) == 0 {
// No matching contexts, return empty result
return []*telemetrytypes.TelemetryFieldKey{}, true, nil
}
// Combine queries with UNION ALL
var limit int
for _, fieldKeySelector := range fieldKeySelectors {
limit += fieldKeySelector.Limit
@@ -556,7 +525,15 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, orgID valuer.UUID,
limit = 1000
}
mainQuery := fmt.Sprintf(`
keys := []*telemetrytypes.TelemetryFieldKey{}
parentTypes := make(map[string][]telemetrytypes.FieldDataType)
rowCount := 0
// the log and scope contexts have no keys table; they are served by the
// static fields appended below
if len(queries) > 0 {
// Combine queries with UNION ALL
mainQuery := fmt.Sprintf(`
SELECT tag_key, tag_type, tag_data_type, max(priority) as priority
FROM (
%s
@@ -566,103 +543,75 @@ func (t *telemetryMetaStore) getLogsKeys(ctx context.Context, orgID valuer.UUID,
LIMIT %d
`, strings.Join(queries, " UNION ALL "), limit+1)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
if err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
}
defer rows.Close()
keys := []*telemetrytypes.TelemetryFieldKey{}
parentTypes := make(map[string][]telemetrytypes.FieldDataType)
rowCount := 0
searchTexts := []string{}
// Collect search texts for static field matching
for _, fieldKeySelector := range fieldKeySelectors {
searchTexts = append(searchTexts, fieldKeySelector.Name)
}
for rows.Next() {
rowCount++
// reached the limit, we know there are more results
if rowCount > limit {
break
}
var name string
var fieldContext telemetrytypes.FieldContext
var fieldDataType telemetrytypes.FieldDataType
var priority uint8
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
if err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
}
defer rows.Close()
// ArrayJSON/ArrayDynamic body rows for parent paths are needed by the JSON access plan
// builder (enrichJSONKeys). Always record them in parentTypes. Only skip adding to keys
// if the user did not also directly request this name — a field like "education" can be
// both a parent of "education[].name" and an explicitly queried field in its own right.
switch fieldDataType {
case telemetrytypes.FieldDataTypeArrayJSON, telemetrytypes.FieldDataTypeArrayDynamic:
if fieldContext == telemetrytypes.FieldContextBody && parentPaths[name] {
parentTypes[name] = append(parentTypes[name], fieldDataType)
if !mapOfRequestedSelectors[name] {
continue // skip; don't register the key.
for rows.Next() {
rowCount++
// reached the limit, we know there are more results
if rowCount > limit {
break
}
var name string
var fieldContext telemetrytypes.FieldContext
var fieldDataType telemetrytypes.FieldDataType
var priority uint8
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
if err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
}
// ArrayJSON/ArrayDynamic body rows for parent paths are needed by the JSON access plan
// builder (enrichJSONKeys). Always record them in parentTypes. Only skip adding to keys
// if the user did not also directly request this name — a field like "education" can be
// both a parent of "education[].name" and an explicitly queried field in its own right.
switch fieldDataType {
case telemetrytypes.FieldDataTypeArrayJSON, telemetrytypes.FieldDataTypeArrayDynamic:
if fieldContext == telemetrytypes.FieldContextBody && parentPaths[name] {
parentTypes[name] = append(parentTypes[name], fieldDataType)
if !mapOfRequestedSelectors[name] {
continue // skip; don't register the key.
}
}
}
}
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
// if there is no materialised column, create a key with the field context and data type
if !ok {
key = &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: fieldContext,
FieldDataType: fieldDataType,
// if there is no materialised column, create a key with the field context and data type
if !ok {
key = &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: fieldContext,
FieldDataType: fieldDataType,
}
}
keys = append(keys, key)
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
}
keys = append(keys, key)
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
}
if rows.Err() != nil {
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
if rows.Err() != nil {
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
}
}
// hit the limit? (only counting DB results)
complete := rowCount <= limit
staticKeys := []string{}
staticKeys = append(staticKeys, maps.Keys(logstelemetryschema.IntrinsicFields)...)
// Add matching intrinsic and matching calculated fields
// These don't count towards the limit
for _, key := range staticKeys {
found := false
for _, v := range searchTexts {
if v == "" || strings.Contains(key, v) {
found = true
break
}
// Add the matching intrinsic columns. These don't count towards the limit
for _, field := range maps.Values(logstelemetryschema.IntrinsicFields) {
if !staticFieldMatchesAny(field, fieldKeySelectors) {
continue
}
if found {
if field, exists := logstelemetryschema.IntrinsicFields[key]; exists {
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
keys = append(keys, &field)
}
continue
}
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
Name: key,
FieldContext: telemetrytypes.FieldContextLog,
Signal: telemetrytypes.SignalLogs,
})
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
continue
}
keys = append(keys, &field)
}
// enrich body keys with promoted paths, indexes, and JSON access plans
@@ -806,10 +755,6 @@ func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors
allArgs = append(allArgs, args...)
}
if len(queries) == 0 {
return []*telemetrytypes.TelemetryFieldKey{}, true, nil
}
var limit int
for _, fieldKeySelector := range fieldKeySelectors {
limit += fieldKeySelector.Limit
@@ -818,7 +763,13 @@ func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors
limit = 1000
}
mainQuery := fmt.Sprintf(`
keys := []*telemetrytypes.TelemetryFieldKey{}
rowCount := 0
// the log and scope contexts have no keys table; they are served by the
// static fields appended below
if len(queries) > 0 {
mainQuery := fmt.Sprintf(`
SELECT tag_key, tag_type, tag_data_type, max(priority) as priority
FROM (
%s
@@ -828,73 +779,57 @@ func (t *telemetryMetaStore) getAuditKeys(ctx context.Context, fieldKeySelectors
LIMIT %d
`, strings.Join(queries, " UNION ALL "), limit+1)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
if err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
}
defer rows.Close()
keys := []*telemetrytypes.TelemetryFieldKey{}
rowCount := 0
searchTexts := []string{}
for _, fieldKeySelector := range fieldKeySelectors {
searchTexts = append(searchTexts, fieldKeySelector.Name)
}
for rows.Next() {
rowCount++
if rowCount > limit {
break
}
var name string
var fieldContext telemetrytypes.FieldContext
var fieldDataType telemetrytypes.FieldDataType
var priority uint8
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, mainQuery, allArgs...)
if err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
}
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
defer rows.Close()
if !ok {
key = &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: fieldContext,
FieldDataType: fieldDataType,
for rows.Next() {
rowCount++
if rowCount > limit {
break
}
var name string
var fieldContext telemetrytypes.FieldContext
var fieldDataType telemetrytypes.FieldDataType
var priority uint8
err = rows.Scan(&name, &fieldContext, &fieldDataType, &priority)
if err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
}
key, ok := mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()]
if !ok {
key = &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: fieldContext,
FieldDataType: fieldDataType,
}
}
keys = append(keys, key)
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
}
keys = append(keys, key)
mapOfKeys[name+";"+fieldContext.StringValue()+";"+fieldDataType.StringValue()] = key
}
if rows.Err() != nil {
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
if rows.Err() != nil {
return nil, false, errors.Wrap(rows.Err(), errors.TypeInternal, errors.CodeInternal, ErrFailedToGetAuditKeys.Error())
}
}
complete := rowCount <= limit
// Add intrinsic audit fields (same as logs intrinsics: body, severity_text, etc.)
staticKeys := maps.Keys(audittelemetryschema.IntrinsicFields)
for _, key := range staticKeys {
found := false
for _, v := range searchTexts {
if v == "" || strings.Contains(key, v) {
found = true
break
}
// Add the matching intrinsic audit fields (same as logs intrinsics: body, severity_text, etc.)
for _, field := range maps.Values(audittelemetryschema.IntrinsicFields) {
if !staticFieldMatchesAny(field, fieldKeySelectors) {
continue
}
if found {
if field, exists := audittelemetryschema.IntrinsicFields[key]; exists {
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; !added {
keys = append(keys, &field)
}
}
if _, added := mapOfKeys[field.Name+";"+field.FieldContext.StringValue()+";"+field.FieldDataType.StringValue()]; added {
continue
}
keys = append(keys, &field)
}
return keys, complete, nil
@@ -1091,9 +1026,12 @@ func (t *telemetryMetaStore) getMeterSourceMetricKeys(ctx context.Context, field
if err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetMeterKeys.Error())
}
// meter labels are stored as strings in the labels JSON and have no
// attribute context, so only the data type is known
keys = append(keys, &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalMetrics,
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
}
@@ -1506,88 +1444,13 @@ func (t *telemetryMetaStore) getSpanFieldValues(ctx context.Context, fieldValueS
instrumentationtypes.CodeNamespace: "metadata",
instrumentationtypes.CodeFunctionName: "getSpanFieldValues",
})
// build the query to get the keys from the spans that match the field selection criteria
limit := fieldValueSelector.Limit
if limit == 0 {
limit = 50
if values, ok := spanScopeFieldValues(fieldValueSelector.Name, fieldValueSelector.Value); ok {
return values, true, nil
}
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(t.tracesDBName + "." + t.tracesFieldsTblName)
if fieldValueSelector.Name != "" {
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
}
// now look at the field context
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextUnspecified {
sb.Where(sb.E("tag_type", fieldValueSelector.FieldContext.TagType()))
}
// now look at the field data type
if fieldValueSelector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
sb.Where(sb.E("tag_data_type", fieldValueSelector.FieldDataType.TagDataType()))
}
if fieldValueSelector.Value != "" {
switch fieldValueSelector.FieldDataType {
case telemetrytypes.FieldDataTypeString:
sb.Where(sb.ILike("string_value", "%"+escapeForLike(fieldValueSelector.Value)+"%"))
case telemetrytypes.FieldDataTypeNumber:
sb.Where(sb.IsNotNull("number_value"))
sb.Where(sb.ILike("toString(number_value)", "%"+escapeForLike(fieldValueSelector.Value)+"%"))
case telemetrytypes.FieldDataTypeUnspecified:
// or b/w string and number
sb.Where(sb.Or(
sb.ILike("string_value", "%"+escapeForLike(fieldValueSelector.Value)+"%"),
sb.ILike("toString(number_value)", "%"+escapeForLike(fieldValueSelector.Value)+"%"),
))
}
}
// query one extra to check if we hit the limit
sb.Limit(limit + 1)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
}
defer rows.Close()
values := &telemetrytypes.TelemetryFieldValues{}
seen := make(map[string]bool)
rowCount := 0
totalCount := 0 // Track total unique values
for rows.Next() {
rowCount++
var stringValue string
var numberValue float64
if err := rows.Scan(&stringValue, &numberValue); err != nil {
return nil, false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
}
// Only add values if we haven't hit the limit yet
if totalCount < limit {
if _, ok := seen[stringValue]; !ok && stringValue != "" {
values.StringValues = append(values.StringValues, stringValue)
seen[stringValue] = true
totalCount++
}
if _, ok := seen[fmt.Sprintf("%f", numberValue)]; !ok && numberValue != 0 && totalCount < limit {
values.NumberValues = append(values.NumberValues, numberValue)
seen[fmt.Sprintf("%f", numberValue)] = true
totalCount++
}
}
}
// hit the limit?
complete := rowCount <= limit
return values, complete, nil
knownBool := isKnownBoolField(fieldValueSelector, tracestelemetryschema.IntrinsicFields, tracestelemetryschema.CalculatedFields)
// unix_milli is the hour of the span start
return t.getTagTableValues(ctx, t.tracesDBName+"."+t.tracesFieldsTblName, fieldValueSelector, knownBool)
}
func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
@@ -1596,17 +1459,77 @@ func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, fieldValueSe
instrumentationtypes.CodeNamespace: "metadata",
instrumentationtypes.CodeFunctionName: "getLogFieldValues",
})
// build the query to get the keys from the spans that match the field selection criteria
knownBool := isKnownBoolField(fieldValueSelector, logstelemetryschema.IntrinsicFields)
// unix_milli is the hour the log was ingested, not the log's own timestamp
return t.getTagTableValues(ctx, t.logsDBName+"."+t.logsFieldsTblName, fieldValueSelector, knownBool)
}
// tagTableSinceDay restricts rows to the tag table's day partitions from the
// start's day on. Partitions are toDate(unix_milli / 1000) in the server's
// timezone, and a value's surviving row within a day carries whichever hour
// was inserted last, so the day is the finest safe unit.
func tagTableSinceDay(sb *sqlbuilder.SelectBuilder, startUnixMilli int64) {
if startUnixMilli != 0 {
sb.Where(fmt.Sprintf("toDate(unix_milli / 1000) >= toDate(%d)", startUnixMilli/1000))
}
}
// tagTableHasBoolRows reports whether the tag table holds a bool row for the
// key. Bool rows carry no value, so one row is enough to know the key takes
// the values true and false.
func (t *telemetryMetaStore) tagTableHasBoolRows(ctx context.Context, table string, selector *telemetrytypes.FieldValueSelector) (bool, error) {
sb := sqlbuilder.Select("1").From(table)
sb.Where(sb.E("tag_key", selector.Name))
sb.Where(sb.E("tag_data_type", telemetrytypes.FieldDataTypeBool.TagDataType()))
if selector.FieldContext != telemetrytypes.FieldContextUnspecified {
sb.Where(sb.E("tag_type", selector.FieldContext.TagType()))
}
tagTableSinceDay(sb, selector.StartUnixMilli)
sb.Limit(1)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return false, errors.Wrap(err, errors.TypeInternal, errors.CodeInternal, ErrFailedToGetLogsKeys.Error())
}
defer rows.Close()
return rows.Next(), rows.Err()
}
// getTagTableValues returns the string and number values of the key from a
// tag table, and true and false when the key is a known bool field or the
// table holds bool rows for it. Bool rows do not count towards the limit.
func (t *telemetryMetaStore) getTagTableValues(ctx context.Context, table string, fieldValueSelector *telemetrytypes.FieldValueSelector, knownBool bool) (*telemetrytypes.TelemetryFieldValues, bool, error) {
limit := fieldValueSelector.Limit
if limit == 0 {
limit = 50
}
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(t.logsDBName + "." + t.logsFieldsTblName)
values := &telemetrytypes.TelemetryFieldValues{}
if knownBool {
values.BoolValues = boolFieldValues(fieldValueSelector.Value).BoolValues
if fieldValueSelector.FieldDataType == telemetrytypes.FieldDataTypeBool {
return values, true, nil
}
} else if fieldValueSelector.FieldDataType == telemetrytypes.FieldDataTypeUnspecified {
hasBoolRows, err := t.tagTableHasBoolRows(ctx, table, fieldValueSelector)
if err != nil {
return nil, false, err
}
if hasBoolRows {
values.BoolValues = boolFieldValues(fieldValueSelector.Value).BoolValues
}
}
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(table)
if fieldValueSelector.Name != "" {
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
}
sb.Where(sb.NE("tag_data_type", telemetrytypes.FieldDataTypeBool.TagDataType()))
tagTableSinceDay(sb, fieldValueSelector.StartUnixMilli)
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextUnspecified {
sb.Where(sb.E("tag_type", fieldValueSelector.FieldContext.TagType()))
@@ -1643,7 +1566,6 @@ func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, fieldValueSe
}
defer rows.Close()
values := &telemetrytypes.TelemetryFieldValues{}
seen := make(map[string]bool)
rowCount := 0
totalCount := 0 // Track total unique values
@@ -2097,6 +2019,18 @@ func populateAllUnspecifiedValues(allUnspecifiedValues *telemetrytypes.Telemetry
}
}
for _, value := range values.BoolValues {
if totalCount >= limit {
complete = false
break
}
if _, ok := mapOfValues[value]; !ok {
mapOfValues[value] = true
allUnspecifiedValues.BoolValues = append(allUnspecifiedValues.BoolValues, value)
totalCount++
}
}
for _, value := range values.RelatedValues {
if totalCount >= limit {
complete = false
@@ -2467,6 +2401,10 @@ func (k *telemetryMetaStore) fetchEvolutionEntryFromClickHouse(ctx context.Conte
// updateColumnEvolutionMetadataForKeys updates the evolution field for keys.
func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Context, keysToUpdate []*telemetrytypes.TelemetryFieldKey) error {
// an empty selector list would run the evolution query without a filter
if len(keysToUpdate) == 0 {
return nil
}
var metadataKeySelectors []*telemetrytypes.EvolutionSelector
for _, keySelector := range keysToUpdate {

View File

@@ -0,0 +1,53 @@
package telemetrymetadata
import (
"strings"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func staticFieldMatchesAny(field telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) bool {
for _, selector := range selectors {
if staticFieldMatches(field, selector) {
return true
}
}
return false
}
// staticFieldMatches mirrors the keys-table lookup for a static field: the
// requested context and data type, when given, must agree with the field's,
// and the name matches case-insensitively, as a substring for fuzzy selectors
// and as the whole name for exact ones.
func staticFieldMatches(field telemetrytypes.TelemetryFieldKey, selector *telemetrytypes.FieldKeySelector) bool {
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && selector.FieldContext != field.FieldContext {
return false
}
if selector.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && !sameDataTypeFamily(selector.FieldDataType, field.FieldDataType) {
return false
}
if selector.Name == "" {
return true
}
if selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeExact {
return strings.EqualFold(field.Name, selector.Name)
}
return strings.Contains(strings.ToLower(field.Name), strings.ToLower(selector.Name))
}
// sameDataTypeFamily treats the numeric types as one family: static fields
// declare "number" while callers may ask for int64 or float64.
func sameDataTypeFamily(requested, actual telemetrytypes.FieldDataType) bool {
if requested == actual {
return true
}
return isNumericDataType(requested) && isNumericDataType(actual)
}
func isNumericDataType(dataType telemetrytypes.FieldDataType) bool {
switch dataType {
case telemetrytypes.FieldDataTypeNumber, telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64:
return true
}
return false
}

View File

@@ -392,6 +392,23 @@ var (
SpanSearchScopeRoot = "isroot"
SpanSearchScopeEntryPoint = "isentrypoint"
// SpanScopeFields are the span selectors that are not columns: they only
// filter with the value true.
SpanScopeFields = map[string]telemetrytypes.TelemetryFieldKey{
"isRoot": {
Name: "isRoot",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
"isEntryPoint": {
Name: "isEntryPoint",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
}
// IntrinsicSpanFields lists the intrinsic span columns, in the order they
// should appear when a raw query expands its SelectFields.
IntrinsicSpanFields = []telemetrytypes.TelemetryFieldKey{

View File

@@ -173,18 +173,18 @@ func NewSourceFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFi
// NewDefaultQuickFilter generates default filters for all supported sources.
func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
tracesFilters := []telemetrytypes.TelemetryFieldKey{
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeNumber},
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "hasError", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeBool},
{Name: "has_error", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeBool},
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "name", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "name", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "rpc.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "response_status_code", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http_host", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "response_status_code", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http_host", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http.route", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http_url", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "trace_id", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http_url", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "trace_id", FieldContext: telemetrytypes.FieldContextSpan, FieldDataType: telemetrytypes.FieldDataTypeString},
}
logsFilters := []telemetrytypes.TelemetryFieldKey{

View File

@@ -70,9 +70,9 @@ require (
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/goleak v1.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
@@ -86,8 +86,8 @@ require (
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.272.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -355,16 +355,16 @@ go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0/go.mod h1:gO9smoZe9KnZcJCqcB0lMmQ4Z5VEifYmjMTpnwtTSuQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -405,12 +405,12 @@ gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,235 @@
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.traces import Traces
@pytest.mark.parametrize(
"signal,field_context,present,absent",
[
pytest.param("logs", "log", {"severity_text": "log", "body": "log", "trace_id": "log"}, ["code.file", "scope_name"], id="log_context_lists_log_intrinsics"),
pytest.param("logs", "scope", {"scope_name": "scope", "scope_version": "scope"}, ["severity_text", "body", "code.file"], id="scope_context_lists_scope_intrinsics_for_logs"),
pytest.param("logs", "attribute", {"code.file": "attribute"}, ["body", "scope_name"], id="attribute_context_excludes_log_intrinsics"),
pytest.param("traces", "span", {"name": "span", "has_error": "span", "isRoot": "span", "http.method": "attribute"}, ["scope.name"], id="span_context_lists_span_intrinsics_and_attributes"),
pytest.param("traces", "scope", {"scope.name": "scope", "scope.version": "scope"}, ["name", "has_error", "isRoot", "http.method", "host.name"], id="scope_context_lists_scope_intrinsics_for_traces"),
pytest.param("traces", "resource", {"host.name": "resource"}, ["name", "has_error", "isRoot", "http.method"], id="resource_context_excludes_span_intrinsics"),
pytest.param("traces", "attribute", {"http.method": "attribute"}, ["name", "has_error", "isRoot", "host.name"], id="attribute_context_excludes_span_intrinsics"),
],
)
def test_fields_keys_by_context(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
signal: str,
field_context: str,
present: dict[str, str],
absent: list[str],
) -> None:
"""
Setup:
Insert a log with a code.file attribute and a span with an http.method attribute and a host.name resource.
Tests:
1. Keys for a context list that context's intrinsic columns and the stored keys the context maps to,
each with its context; intrinsics of other contexts are not listed. The span context also keeps
listing attributes because `span.<attribute>` resolves attributes in queries.
"""
now = datetime.now(tz=UTC)
insert_logs([Logs(timestamp=now, attributes={"code.file": "/opt/integration.go"}, body="a log line")])
insert_traces([Traces(timestamp=now, resources={"host.name": "linux-001"}, attributes={"http.method": "GET"})])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
params={"signal": signal, "fieldContext": field_context},
)
assert response.status_code == HTTPStatus.OK
keys = response.json()["data"]["keys"]
listed = {name: [key["fieldContext"] for key in keys.get(name, [])] for name in present}
assert listed == {name: [context] for name, context in present.items()}, f"keys for the {field_context} context"
assert [name for name in absent if name in keys] == [], f"keys that do not belong to the {field_context} context"
@pytest.mark.parametrize(
"signal,field_context,field_data_type,present,absent",
[
pytest.param("traces", "span", "float64", ["duration_nano", "status_code"], ["name", "has_error"], id="float64_matches_number_span_intrinsics"),
pytest.param("traces", "span", "int64", ["duration_nano", "status_code"], ["name", "has_error"], id="int64_matches_number_span_intrinsics"),
pytest.param("traces", "span", "bool", ["has_error", "isRoot", "isEntryPoint"], ["name", "duration_nano"], id="bool_matches_bool_span_intrinsics"),
pytest.param("traces", "span", "string", ["name", "http_method"], ["duration_nano", "has_error"], id="string_matches_string_span_intrinsics"),
pytest.param("logs", "log", "number", ["severity_number", "trace_flags"], ["severity_text", "body"], id="number_matches_number_log_intrinsics"),
],
)
def test_fields_keys_by_data_type(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
signal: str,
field_context: str,
field_data_type: str,
present: list[str],
absent: list[str],
) -> None:
"""
Tests:
1. A data type filter keeps the intrinsic columns of that type; number, int64 and float64 are one family.
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
params={"signal": signal, "fieldContext": field_context, "fieldDataType": field_data_type},
)
assert response.status_code == HTTPStatus.OK
keys = response.json()["data"]["keys"]
assert [name for name in present if name not in keys] == [], f"intrinsics of type {field_data_type}"
assert [name for name in absent if name in keys] == [], f"intrinsics not of type {field_data_type}"
@pytest.mark.parametrize(
"signal,search_text,present",
[
pytest.param("logs", "SEVERITY", ["severity_text", "severity_number"], id="upper_case_search_logs"),
pytest.param("traces", "HTTP_", ["http_method", "http_host", "http_url"], id="upper_case_search_traces"),
pytest.param("traces", "Duration", ["duration_nano"], id="mixed_case_search_traces"),
pytest.param("traces", "span.HAS_ERR", ["has_error"], id="context_prefix_with_upper_case_search"),
],
)
def test_fields_keys_search_matches_intrinsics_case_insensitively(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
signal: str,
search_text: str,
present: list[str],
) -> None:
"""
Tests:
1. The search text matches intrinsic columns case-insensitively, as it does for stored keys,
with or without a context prefix.
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
params={"signal": signal, "searchText": search_text},
)
assert response.status_code == HTTPStatus.OK
keys = response.json()["data"]["keys"]
assert [name for name in present if name not in keys] == [], f"intrinsics matching {search_text!r}"
@pytest.mark.parametrize(
"signal,params,expected",
[
pytest.param("traces", {"name": "has_error"}, [True, False], id="calculated_bool_span_field"),
pytest.param("traces", {"name": "has_error", "fieldContext": "span"}, [True, False], id="calculated_bool_span_field_with_context"),
pytest.param("traces", {"name": "has_error", "searchText": "tr"}, [True], id="search_text_narrows_bool_values"),
pytest.param("traces", {"name": "isRoot"}, [True], id="span_scope_field_is_true_only"),
pytest.param("logs", {"name": "retry"}, [True, False], id="bool_attribute_from_tag_rows"),
pytest.param("logs", {"name": "retry", "fieldContext": "attribute"}, [True, False], id="bool_attribute_with_context"),
pytest.param("logs", {"name": "retry", "searchText": "tr"}, [True], id="search_text_narrows_stored_bool_values"),
pytest.param("logs", {"name": "never_seen", "fieldDataType": "bool"}, [True, False], id="declared_bool_type_needs_no_rows"),
],
)
def test_fields_values_bool_fields(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
signal: str,
params: dict[str, str],
expected: list[bool],
) -> None:
"""
Setup:
Insert a log with a bool attribute.
Tests:
1. Values for a bool field are true and false (narrowed by the search text): for the calculated span
field, for a stored bool attribute whose tag rows carry no value, and for a key the caller
declares bool.
2. A span scope selector (isRoot) only takes true.
"""
insert_logs([Logs(timestamp=datetime.now(tz=UTC), attributes={"retry": True}, body="retrying")])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
params={"signal": signal, **params},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["values"]["boolValues"] == expected
assert response.json()["data"]["complete"] is True
def test_fields_values_start_excludes_span_values_not_seen_since_the_day(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Insert a span three days old and a span now, with different service names.
Tests:
1. Values with startUnixMilli an hour ago contain only the service seen today: the start is
floored to the day, the tag table's deduplication unit.
2. Values without a start contain both services.
Logs are not covered: the logs collector stamps tag rows with the ingestion hour, not the
log's timestamp, and the fixture writes the log's timestamp.
"""
signal = "traces"
now = datetime.now(tz=UTC)
insert_traces(
[
Traces(timestamp=now - timedelta(days=3), resources={"service.name": "archived-service"}),
Traces(timestamp=now, resources={"service.name": "live-service"}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
params={
"signal": signal,
"name": "service.name",
"startUnixMilli": int((now - timedelta(hours=1)).timestamp() * 1000),
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["values"]["stringValues"] == ["live-service"], "values last seen before the start must be dropped"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
params={"signal": signal, "name": "service.name"},
)
assert response.status_code == HTTPStatus.OK
assert set(response.json()["data"]["values"]["stringValues"]) == {"archived-service", "live-service"}

View File

@@ -71,7 +71,7 @@ def test_v1_get_serves_legacy_shape(
assert response.status_code == HTTPStatus.OK, response.text
filters = response.json()["data"]["filters"]
assert filters[0]["key"] == "duration_nano"
assert filters[0]["type"] == "tag"
assert filters[0]["type"] == "", "span fields have no v3 attribute type"
assert filters[0]["dataType"] == "float64"
assert all("name" not in legacy_filter for legacy_filter in filters)
@@ -274,3 +274,36 @@ def test_update_quick_filters_rejects_invalid_input(
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_default_traces_filters_are_served_as_the_fields_api_serves_them(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters/traces"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
filters = {field_key["name"]: field_key for field_key in response.json()["data"]["filters"]}
assert "hasError" not in filters
assert (filters["has_error"]["fieldContext"], filters["has_error"]["fieldDataType"]) == ("span", "bool")
assert (filters["name"]["fieldContext"], filters["name"]["fieldDataType"]) == ("span", "string")
assert (filters["duration_nano"]["fieldContext"], filters["duration_nano"]["fieldDataType"]) == ("span", "number")
assert (filters["http.route"]["fieldContext"], filters["http.route"]["fieldDataType"]) == ("attribute", "string")
for name in ("has_error", "name"):
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
params={"signal": "traces", "searchText": name, "fieldContext": filters[name]["fieldContext"]},
)
assert response.status_code == HTTPStatus.OK, response.text
served = response.json()["data"]["keys"][name]
assert (filters[name]["fieldContext"], filters[name]["fieldDataType"]) in [(key["fieldContext"], key["fieldDataType"]) for key in served]