Compare commits

...

8 Commits

Author SHA1 Message Date
Tushar Vats
c200693b50 feat(querier): expose ValidateRequest and log invalid in-process requests
Handlers validate through the querier so its limit stays private.
In-process callers only warn for now; QueryRange still rejects bad
time windows.
2026-11-03 17:22:23 +05:30
Tushar Vats
0d965d63c6 feat(querier): make the future end-time limit configurable
Handlers have no querier config, so the check moves from Validate into
QueryRange and QueryRangePreview; in-process callers get it too.
2026-11-03 16:44:22 +05:30
Tushar Vats
ed50e4aed1 fix(querier): stop zero-start OOM and reject malformed time windows (#13246)
#### Description

- `makeBuckets` looped forever (and OOM-killed the query service) when
`start` was 0: `curEnd - bucket` wrapped below zero and never reached
`start`. Each bucket now starts at `start` once the remaining range
fits, so the loop always ends. A reversed window (DESC cursor reaching
the start boundary) yields no buckets instead of wrapping.
- `start`/`end` are coerced to epoch ms at decode, so validation
compares like units; `QueryRange` still coerces for in-process callers
(domain list/info).
- Validation rejects a missing/zero `start`, `start >= end`, and an
`end` more than 24h in the future (e.g. a 30-day duration read as a
seconds epoch → year 2052). These are well-formedness checks:
unconditional, no config, no opt-out.
- Integration tests freeze the rejections and the step clamp for a
2-year span at a 1s step (logs/traces 42045s, 1502 buckets; metrics
210600s).

#### Issues closed by this PR

Fixes https://github.com/SigNoz/events-pod/issues/178

#### Additional Information

- Behavior change for API clients: `/api/v5/query_range` now returns 400
for a zero `start` or an `end` more than 24h ahead.
- Addresses the validation half of
https://github.com/SigNoz/events-pod/issues/207. Capping how wide a
window may be is deployment capacity rather than well-formedness, so it
is split into SigNoz/signoz#13324, which closes that issue.
2026-11-03 10:58:48 +00:00
Nikhil Mantri
a2d281881c feat(authz): rule view FGA (#13251)
#### Description

- Move `/api/v2/rule_views` routes from role gates to `CheckResources`
on `rule:list`, dashboard views parity.
- Migration 143 backfills rule tuples for managed roles, the first PR in
the stack to check a rule scope.
- Integration tests cover a viewer on all four routes and a custom role
without `rule:list`.

#### Issues closed by this PR

Part of SigNoz/pulse-pod#286
Part of SigNoz/pulse-pod#355

#### Additional Information

Third of five stacked PRs splitting #12927. Stacked on #13250.
2026-11-03 10:20:36 +00:00
ashu8912
74d35ebafa fix(channels): reset previous type's template values on type switch (#13374)
#### Description

- Switching channel type now clears the previous type's prefilled
templates that the new type doesn't set, so e.g. Slack → Telegram no
longer sends Slack's title, and PagerDuty → Opsgenie no longer sends a
hidden `details` map. `sendResolved` is kept.
- PagerDuty no longer prefills Client URL with a dead placeholder host;
left empty, the backend links to the instance. Help text says so.
- No page story covers the channel form; the change affects form values,
not layout.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#669
2026-11-03 09:44:32 +00:00
Nikhil Soni
12108b410a fix(traces-qb): synthesized attribute keys follow the JSON column evolution (#13307)
#### Description

- A span attribute metadata does not report fell back to the legacy maps
in every window. It now follows the JSON rollout like a reported key.
- The traces storage reads the column-wide evolution rows in `Fallback`,
where those keys are synthesized, so callers and requested keys stay
untouched.
- The rows come from a separate `SchemaMetadataStore`, built once next
to the metadata store and shared by every traces storage. Found rows are
cached; an empty result is not, since the migrator can add the row while
the service runs.

#### Additional Information

- Alternative to #13168, which carried the rows on the requested key
through every caller.
- The storage gets only `SchemaMetadataStore`; `MetadataStore` is
unchanged.
- New `querier.schemametadatastore.cache_ttl` (default `0`, cached for
the life of the process). The integration server sets `1ns` since its
tests reseed the rollout row between cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01SXgj9vFWJku1UdD6MDZTmb
2026-11-03 09:36:40 +00:00
Naman Verma
9d91040a55 test: fix apt-get not found error in integration tests (#13367)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- `ubuntu-latest` now resolves to Ubuntu 26.04, which no longer ships
`apt-key`, so the webdriver step failed before any test ran on the
basepath and callbackauthn suites.
- The Chrome signing key is dearmored into `/usr/share/keyrings` and the
apt source references it with `signed-by=`, which works on both 24.04
and 26.04 images.


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

Closes https://github.com/SigNoz/pulse-pod/issues/672
2026-11-03 09:35:11 +00:00
Abhi kumar
65084ba064 fix(dashboards): label unstacked bar layout as overlap (#13369)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Bar chart "Series layout" offered "Side by side", but unstacked bars
render overlapping at the same x. Renamed to "Overlap" with a matching
tile drawing and help text, consistent with the area chart's option.

Before
<img width="388" height="157" alt="image"
src="https://github.com/user-attachments/assets/0b9ecab4-ece8-472c-b924-66c053d2cdc9"
/>


After
<img width="420" height="184" alt="image"
src="https://github.com/user-attachments/assets/680c7bdc-0c49-4111-81f5-e0c633df6715"
/>


#### Issues closed by this PR

Closes SigNoz/pulse-pod#673
2026-11-03 06:09:19 +00:00
87 changed files with 1743 additions and 283 deletions

View File

@@ -106,8 +106,8 @@ jobs:
- name: webdriver
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/google-chrome.gpg
echo "deb [signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
sudo apt-get update -qqy
sudo apt-get -qqy install google-chrome-stable
CHROME_VERSION=$(google-chrome-stable --version)

View File

@@ -16,6 +16,14 @@ packages:
filename: "maintenance.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/types/telemetrytypes:
interfaces:
SchemaMetadataStore:
config:
dir: '{{.InterfaceDir}}/telemetrytypestest'
filename: "schema_metadata_store.go"
structname: 'Mock{{.InterfaceName}}'
pkgname: '{{.SrcPackageName}}test'
github.com/SigNoz/signoz/pkg/tokenizer:
config:
all: true

View File

@@ -178,6 +178,12 @@ querier:
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.
log_trace_id_window_padding: 5m
# How far past now a query's end time may be, tolerating client clock skew.
# Requests ending later are rejected as malformed.
max_end_time_ahead: 24h
schemametadatastore:
# How long found schema metadata stays cached; 0 caches it for the life of the process.
cache_ttl: 0s
##################### TelemetryStore #####################
telemetrystore:

View File

@@ -25153,9 +25153,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: List rule saved views
tags:
- rules
@@ -25211,9 +25211,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Create rule saved view
tags:
- rules
@@ -25265,9 +25265,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Delete rule saved view
tags:
- rules
@@ -25334,9 +25334,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Update rule saved view
tags:
- rules

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"math"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -75,36 +76,54 @@ func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID
})
// TODO(srikanthccv): parallelize this?
p.logger.InfoContext(ctx, "fetching results for current period", slog.Any("anomaly_current_period_query", params.CurrentPeriodQuery))
if err := p.querier.ValidateRequest(&params.CurrentPeriodQuery); err != nil {
p.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
currentPeriodResults, err := p.querier.QueryRange(ctx, orgID, &params.CurrentPeriodQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for past period", slog.Any("anomaly_past_period_query", params.PastPeriodQuery))
if err := p.querier.ValidateRequest(&params.PastPeriodQuery); err != nil {
p.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
pastPeriodResults, err := p.querier.QueryRange(ctx, orgID, &params.PastPeriodQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for current season", slog.Any("anomaly_current_season_query", params.CurrentSeasonQuery))
if err := p.querier.ValidateRequest(&params.CurrentSeasonQuery); err != nil {
p.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
currentSeasonResults, err := p.querier.QueryRange(ctx, orgID, &params.CurrentSeasonQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for past season", slog.Any("anomaly_past_season_query", params.PastSeasonQuery))
if err := p.querier.ValidateRequest(&params.PastSeasonQuery); err != nil {
p.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
pastSeasonResults, err := p.querier.QueryRange(ctx, orgID, &params.PastSeasonQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for past 2 season", slog.Any("anomaly_past_2season_query", params.Past2SeasonQuery))
if err := p.querier.ValidateRequest(&params.Past2SeasonQuery); err != nil {
p.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
past2SeasonResults, err := p.querier.QueryRange(ctx, orgID, &params.Past2SeasonQuery)
if err != nil {
return nil, err
}
p.logger.InfoContext(ctx, "fetching results for past 3 season", slog.Any("anomaly_past_3season_query", params.Past3SeasonQuery))
if err := p.querier.ValidateRequest(&params.Past3SeasonQuery); err != nil {
p.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
past3SeasonResults, err := p.querier.QueryRange(ctx, orgID, &params.Past3SeasonQuery)
if err != nil {
return nil, err

View File

@@ -153,6 +153,9 @@ func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer
return nil, err
}
if err := module.querier.ValidateRequest(query); err != nil {
module.settings.Logger().WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
}

View File

@@ -53,7 +53,7 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
return
}
if err := queryRangeRequest.Validate(); err != nil {
if err := h.querier.ValidateRequest(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}

View File

@@ -103,7 +103,7 @@
"placeholder_slack_description": "Description",
"placeholder_pager_description": "Description",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty. Leave empty to link to this SigNoz instance",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",

View File

@@ -169,7 +169,7 @@
"placeholder_opsgenie_message": "Message",
"placeholder_opsgenie_priority": "Priority",
"help_pager_client": "Shows up as event source in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty",
"help_pager_client_url": "Shows up as event source link in Pagerduty. Leave empty to link to this SigNoz instance",
"help_pager_class": "The class/type of the event",
"help_pager_details": "Specify a key-value format (must be a valid json)",
"help_pager_group": "A cluster or grouping of sources",

View File

@@ -85,7 +85,6 @@ export const PagerInitialConfig: Partial<AlertmanagertypesChannelPagerdutyConfig
{{- end }}`,
severity: '{{ (index .Alerts 0).Labels.severity }}',
client: 'SigNoz Alert Manager',
clientUrl: 'https://enter-signoz-host-n-port-here/alerts',
details: {
firing: `{{ .Alerts.Firing | toJson }}`,
resolved: `{{ .Alerts.Resolved | toJson }}`,

View File

@@ -486,4 +486,66 @@ describe('ChannelForm save', () => {
},
TEST_TIMEOUT,
);
it(
"drops the previous type's templates when switching type",
async () => {
let body: Record<string, unknown> | undefined;
server.use(
rest.post(
'http://localhost/api/v2/notification_channels',
async (req, res, ctx) => {
body = await req.json();
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
},
),
);
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Telegram$/ }));
expect(screen.getByTestId('telegram-title-textarea')).toHaveValue('');
await user.type(
screen.getByTestId('channel-name-textbox'),
'telegram alerts',
);
await user.type(screen.getByTestId('telegram-bot-token-textbox'), '123:abc');
await user.type(screen.getByTestId('telegram-chat-id-textbox'), '-100123');
const save = screen.getByTestId('save-channel-button');
await waitFor(
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
FIND,
);
await user.click(save);
await waitFor(() => expect(body).toBeDefined(), FIND);
expect(
(body as { config: { spec: Record<string, unknown> } }).config.spec,
).toStrictEqual({
botToken: '123:abc',
chatId: -100123,
sendResolved: true,
});
},
TEST_TIMEOUT,
);
it(
'leaves the pagerduty client url empty so the backend links to this instance',
async () => {
render(<ChannelForm onDone={jest.fn()} />);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
await user.click(await screen.findByRole('option', { name: /^Pagerduty$/ }));
expect(screen.getByTestId('pager-client-url-textarea')).toHaveValue('');
},
TEST_TIMEOUT,
);
});

View File

@@ -86,9 +86,25 @@ export function useChannelFormState({
setTypeState(nextType);
const nextDefaults = ChannelInitialConfig[nextType];
// the previous type's templates would otherwise be sent as the new
// type's values; sendResolved is a general setting and is kept.
// '' not undefined: a field mounting on undefined falls back to the
// draft's defaults, and blanks are dropped on save anyway
Object.keys(ChannelInitialConfig[type])
.filter((field) => field !== 'sendResolved' && !(field in nextDefaults))
.forEach((field) => {
form.setValue(
field as keyof ChannelFormValues,
'' as ChannelFormValues[keyof ChannelFormValues],
{ shouldDirty: true },
);
});
// shouldDirty, because the type lives outside the form: without it
// picking a different type leaves the form pristine and unsavable.
Object.entries(ChannelInitialConfig[nextType]).forEach(([field, value]) => {
Object.entries(nextDefaults).forEach(([field, value]) => {
form.setValue(
field as keyof ChannelFormValues,
value as ChannelFormValues[keyof ChannelFormValues],

View File

@@ -164,12 +164,12 @@ export const TILE_DRAWINGS = {
<path d="M4 20 C 20 20, 22 12, 32 12 S 44 4, 60 4" />
</TileSvg>
),
barsSideBySide: (
barsOverlap: (
<TileSvg>
<rect x={10} y={10} width={7} height={12} {...FILLED} opacity={0.5} />
<rect x={18} y={4} width={7} height={18} {...FILLED} />
<rect x={36} y={12} width={7} height={10} {...FILLED} opacity={0.5} />
<rect x={44} y={7} width={7} height={15} {...FILLED} />
<rect x={14} y={6} width={11} height={16} {...FILLED} opacity={0.5} />
<rect x={14} y={12} width={11} height={10} {...FILLED} />
<rect x={38} y={10} width={11} height={12} {...FILLED} opacity={0.5} />
<rect x={38} y={14} width={11} height={8} {...FILLED} />
</TileSvg>
),
barsStacked: (

View File

@@ -56,7 +56,7 @@ function VisualizationSection({
value?.timePreference ?? DashboardtypesTimePreferenceDTO.global_time;
const barLayout = value?.stackedBarChart
? BarLayout.STACKED
: BarLayout.SIDE_BY_SIDE;
: BarLayout.OVERLAP;
const stack = value?.stack ?? defaultValue?.stack;
return (

View File

@@ -4,15 +4,15 @@ import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export enum BarLayout {
SIDE_BY_SIDE = 'side',
OVERLAP = 'overlap',
STACKED = 'stacked',
}
export const BAR_LAYOUT_OPTIONS: ConfigTileItem<BarLayout>[] = [
{
value: BarLayout.SIDE_BY_SIDE,
label: 'Side by side',
drawing: TILE_DRAWINGS.barsSideBySide,
value: BarLayout.OVERLAP,
label: 'Overlap',
drawing: TILE_DRAWINGS.barsOverlap,
},
{
value: BarLayout.STACKED,
@@ -22,8 +22,7 @@ export const BAR_LAYOUT_OPTIONS: ConfigTileItem<BarLayout>[] = [
];
export const BAR_LAYOUT_HELP: Record<BarLayout, string> = {
[BarLayout.SIDE_BY_SIDE]:
'Each series gets its own bar, side by side, for easy comparison.',
[BarLayout.OVERLAP]: 'Each series is drawn from zero and may hide others.',
[BarLayout.STACKED]:
'Bars sit on top of each other, so the height shows the total.',
};

View File

@@ -149,61 +149,97 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRuleViews), handler.OpenAPIDef{
ID: "ListRuleViews",
Tags: []string{"rules"},
Summary: "List rule saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Response: new(ruletypes.ListableRuleViews),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.ListRuleViews, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListRuleViews",
Tags: []string{"rules"},
Summary: "List rule saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
Response: new(ruletypes.ListableRuleViews),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.CreateRuleView), handler.OpenAPIDef{
ID: "CreateRuleView",
Tags: []string{"rules"},
Summary: "Create rule saved view",
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
Request: new(ruletypes.PostableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.CreateRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "CreateRuleView",
Tags: []string{"rules"},
Summary: "Create rule saved view",
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
Request: new(ruletypes.PostableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.UpdateRuleView), handler.OpenAPIDef{
ID: "UpdateRuleView",
Tags: []string{"rules"},
Summary: "Update rule saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(ruletypes.UpdatableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.UpdateRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "UpdateRuleView",
Tags: []string{"rules"},
Summary: "Update rule saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
Request: new(ruletypes.UpdatableRuleView),
RequestContentType: "application/json",
Response: new(ruletypes.RuleView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.DeleteRuleView), handler.OpenAPIDef{
ID: "DeleteRuleView",
Tags: []string{"rules"},
Summary: "Delete rule saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.rulerHandler.DeleteRuleView, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "DeleteRuleView",
Tags: []string{"rules"},
Summary: "Delete rule saved view",
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -191,6 +192,9 @@ func (m *module) getTopClusterGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
@@ -232,6 +233,9 @@ func (m *module) getTopContainerGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -4,6 +4,7 @@ import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -182,6 +183,9 @@ func (m *module) getTopDaemonSetGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -4,6 +4,7 @@ import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -174,6 +175,9 @@ func (m *module) getTopDeploymentGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
@@ -321,6 +322,9 @@ func (m *module) getTopHostGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -4,6 +4,7 @@ import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -182,6 +183,9 @@ func (m *module) getTopJobGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/inframonitoring"
@@ -222,6 +223,9 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -325,6 +329,9 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -436,6 +443,9 @@ func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inf
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -552,6 +562,9 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -666,6 +679,9 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -778,6 +794,9 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -877,6 +896,9 @@ func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *infram
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newVolumesTableListQuery())
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
@@ -974,6 +996,9 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -1083,6 +1108,9 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -1192,6 +1220,9 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})
@@ -1301,6 +1332,9 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
g.Go(func() error {
var err error
if err := m.querier.ValidateRequest(fullQueryReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
return err
})

View File

@@ -4,6 +4,7 @@ import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -157,6 +158,9 @@ func (m *module) getTopNamespaceGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -6,6 +6,7 @@ import (
"slices"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
@@ -199,6 +200,9 @@ func (m *module) getTopNodeGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
@@ -239,6 +240,9 @@ func (m *module) getTopPodGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -4,6 +4,7 @@ import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -174,6 +175,9 @@ func (m *module) getTopStatefulSetGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -4,6 +4,7 @@ import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -133,6 +134,9 @@ func (m *module) getTopVolumeGroupsAndMetadata(
}
g.Go(func() error {
if err := m.querier.ValidateRequest(topReq); err != nil {
m.logger.WarnContext(gCtx, "invalid query range request", errors.Attr(err))
}
resp, err := m.querier.QueryRange(gCtx, orgID, topReq)
if err != nil {
return err

View File

@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
@@ -24,10 +26,12 @@ const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
logger *slog.Logger
}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, querier: querier}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier, settings factory.ProviderSettings) llmpricingrule.Module {
logger := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/llmpricingrule/impllmpricingrule").Logger()
return &module{store: store, querier: querier, logger: logger}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -201,6 +205,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
},
}
if err := module.querier.ValidateRequest(req); err != nil {
module.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
resp, err := module.querier.QueryRange(ctx, orgID, req)
if err != nil {
return nil, err

View File

@@ -538,7 +538,7 @@ func TestTracesNumberIndexExpr_MatchesQuerierFilters(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
conditions, _, err := querybuilder.Conditions(context.Background(), queryInfo, tracestelemetryschema.NewStorage(), &key, testCase.operator, testCase.value, map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, false, sb)
conditions, _, err := querybuilder.Conditions(context.Background(), queryInfo, tracestelemetryschema.NewStorage(nil), &key, testCase.operator, testCase.value, map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, false, sb)
require.NoError(t, err)
sb.Where(conditions...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)

View File

@@ -2,8 +2,10 @@ package implrawdataexport
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -14,11 +16,13 @@ import (
type Module struct {
querier querier.Querier
logger *slog.Logger
}
func NewModule(querier querier.Querier) rawdataexport.Module {
func NewModule(querier querier.Querier, settings factory.ProviderSettings) rawdataexport.Module {
return &Module{
querier: querier,
logger: factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/rawdataexport/implrawdataexport").Logger(),
}
}
@@ -28,6 +32,10 @@ func (m *Module) ExportRawData(ctx context.Context, orgID valuer.UUID, rangeRequ
instrumentationtypes.CodeFunctionName: "ExportRawData",
})
if err := m.querier.ValidateRequest(rangeRequest, qbtypes.WithSkipLimitOffsetValidation()); err != nil {
m.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
traceOperatorQueryIndex := rangeRequest.TraceOperatorQueryIndex()
queries := rangeRequest.CompositeQuery.Queries

View File

@@ -3,12 +3,14 @@ package implservices
import (
"context"
"fmt"
"log/slog"
"time"
"strconv"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/services"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
@@ -24,13 +26,15 @@ import (
type module struct {
Querier querier.Querier
TelemetryStore telemetrystore.TelemetryStore
logger *slog.Logger
}
// NewModule constructs the services module with the provided querier dependency.
func NewModule(q querier.Querier, ts telemetrystore.TelemetryStore) services.Module {
func NewModule(q querier.Querier, ts telemetrystore.TelemetryStore, settings factory.ProviderSettings) services.Module {
return &module{
Querier: q,
TelemetryStore: ts,
logger: factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/services/implservices").Logger(),
}
}
@@ -220,6 +224,9 @@ func (m *module) buildQueryRangeRequest(req *servicetypesv1.Request) (*qbtypes.Q
// executeQuery calls the underlying Querier with the provided request.
func (m *module) executeQuery(ctx context.Context, orgUUID valuer.UUID, qr *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
if err := m.Querier.ValidateRequest(qr); err != nil {
m.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
return m.Querier.QueryRange(ctx, orgUUID, qr)
}

View File

@@ -38,7 +38,7 @@ func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, req *
return nil, err
}
if err := queryRangeRequest.Validate(); err != nil {
if err := m.querier.ValidateRequest(queryRangeRequest); err != nil {
return nil, err
}

View File

@@ -64,11 +64,11 @@ type traceStore struct {
flagger flagger.Flagger
}
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *traceStore {
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, schemaMetadataStore telemetrytypes.SchemaMetadataStore, fl flagger.Flagger) *traceStore {
return &traceStore{
telemetryStore: ts,
metadataStore: metadataStore,
storage: tracestelemetryschema.NewStorage(),
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
flagger: fl,
}
}

View File

@@ -49,7 +49,7 @@ func newTestStore(matcher sqlmock.QueryMatcher) *spantypestest.TraceStoreTest {
func newTestStoreWithMetadata(matcher sqlmock.QueryMatcher, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *spantypestest.TraceStoreTest {
ts := telemetrystoretest.New(telemetrystore.Config{}, matcher)
return spantypestest.New(impltracedetail.NewTraceStore(ts, metadataStore, fl), ts.Mock())
return spantypestest.New(impltracedetail.NewTraceStore(ts, metadataStore, nil, fl), ts.Mock())
}
func TestGetTraceBounds(t *testing.T) {

View File

@@ -59,7 +59,7 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
}
// Validate the query request
if err := queryRangeRequest.Validate(); err != nil {
if err := handler.querier.ValidateRequest(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}
@@ -140,7 +140,9 @@ func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request
}
// create the v5 request param
queryRangeRequest := qbtypes.QueryRangeRequest{
Start: start,
// Coerce the window to epoch milliseconds up front (End may be 0 for the
// open-ended stream, which ToMilliSecs leaves untouched).
Start: qbtypes.ToMilliSecs(start),
RequestType: qbtypes.RequestTypeRawStream,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
@@ -186,7 +188,7 @@ func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request
}
// Validate the query request
if err := queryRangeRequest.Validate(); err != nil {
if err := handler.querier.ValidateRequest(&queryRangeRequest); err != nil {
render.Error(rw, err)
return
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/statementbuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
const DefaultMaxConcurrentQueries = 8
@@ -20,6 +21,8 @@ type Config struct {
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
// LogTraceIDWindowPadding is the padding added to narrowed down timerange from trace summary to logs with trace_id filter.
LogTraceIDWindowPadding time.Duration `yaml:"log_trace_id_window_padding" mapstructure:"log_trace_id_window_padding"`
// MaxEndTimeAhead is how far past now a query's end time may be; it tolerates client clock skew.
MaxEndTimeAhead time.Duration `yaml:"max_end_time_ahead" mapstructure:"max_end_time_ahead"`
// Keys sit under querier.skip_resource_fingerprint.
statementbuilder.Config `mapstructure:",squash" yaml:",squash"`
@@ -37,6 +40,7 @@ func newConfig() factory.Config {
FluxInterval: 5 * time.Minute,
MaxConcurrentQueries: DefaultMaxConcurrentQueries,
LogTraceIDWindowPadding: 5 * time.Minute,
MaxEndTimeAhead: qbtypes.DefaultMaxEndTimeAhead,
Config: statementbuilder.NewConfig(),
}
}
@@ -55,6 +59,9 @@ func (c Config) Validate() error {
if c.LogTraceIDWindowPadding < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "log_trace_id_window_padding must not be negative, got %v", c.LogTraceIDWindowPadding)
}
if c.MaxEndTimeAhead <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "max_end_time_ahead must be positive, got %v", c.MaxEndTimeAhead)
}
// Embedded Validate is shadowed by this one; call it explicitly.
if err := c.Config.Validate(); err != nil {
return err

View File

@@ -0,0 +1,46 @@
package querier
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/config"
"github.com/SigNoz/signoz/pkg/config/configtest"
"github.com/SigNoz/signoz/pkg/factory"
)
func TestSchemaMetadataStoreCacheTTLFromEnv(t *testing.T) {
testCases := []struct {
name string
env map[string]string
want time.Duration
}{
{
name: "Unset_CachesForProcessLifetime",
want: 0,
},
{
name: "Nanosecond_ExpiresImmediately",
env: map[string]string{"SIGNOZ_QUERIER_SCHEMAMETADATASTORE_CACHE__TTL": "1ns"},
want: time.Nanosecond,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
for key, value := range testCase.env {
t.Setenv(key, value)
}
conf, err := config.New(context.Background(), configtest.NewResolverConfig(), []factory.ConfigFactory{NewConfigFactory()})
require.NoError(t, err)
actual := Config{}
require.NoError(t, conf.Unmarshal("querier", &actual))
assert.Equal(t, testCase.want, actual.SchemaMetadataStore.CacheTTL)
})
}
}

View File

@@ -11,6 +11,12 @@ func makeBuckets(start, end uint64) []tsRange {
startNS := querybuilder.ToNanoSecs(start)
endNS := querybuilder.ToNanoSecs(end)
// A cursor that reaches the window boundary can leave end <= start; the
// subtractions below are unsigned and would wrap.
if endNS <= startNS {
return nil
}
if endNS-startNS <= uint64(hourNanos) {
return []tsRange{{fromNS: startNS, toNS: endNS}}
}
@@ -20,9 +26,9 @@ func makeBuckets(start, end uint64) []tsRange {
curEnd := endNS
for {
curStart := curEnd - bucket
if curStart < startNS {
curStart = startNS
curStart := startNS
if curEnd-startNS > bucket {
curStart = curEnd - bucket
}
out = append(out, tsRange{fromNS: curStart, toNS: curEnd})

View File

@@ -23,7 +23,7 @@ func (q *querier) QueryRangePreview(
opts qbtypes.QueryRangePreviewOptions,
) (*qbtypes.QueryRangePreviewResponse, error) {
validationOpts, err := req.ValidateRequestScope()
validationOpts, err := req.ValidateRequestScope(q.validationOptions()...)
if err != nil {
return nil, err
}

View File

@@ -43,6 +43,8 @@ type Querier interface {
statsreporter.StatsCollector
// QueryRangePreview validates and renders the queries without executing them.
QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error)
// ValidateRequest checks req against the querier's preconditions; opts add to the querier's own.
ValidateRequest(req *qbtypes.QueryRangeRequest, opts ...qbtypes.ValidationOption) error
}
type querier struct {
@@ -62,6 +64,7 @@ type querier struct {
liveDataRefresh time.Duration
builderConfig builderConfig
maxConcurrentQueries int
config Config
}
var _ Querier = (*querier)(nil)
@@ -80,12 +83,14 @@ func New(
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
bucketCache BucketCache,
flagger flagger.Flagger,
logTraceIDWindowPadding time.Duration,
maxConcurrentQueries int,
config Config,
) *querier {
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
if maxConcurrentQueries <= 0 {
maxConcurrentQueries = DefaultMaxConcurrentQueries
if config.MaxConcurrentQueries <= 0 {
config.MaxConcurrentQueries = DefaultMaxConcurrentQueries
}
if config.MaxEndTimeAhead <= 0 {
config.MaxEndTimeAhead = qbtypes.DefaultMaxEndTimeAhead
}
return &querier{
logger: querierSettings.Logger(),
@@ -103,19 +108,32 @@ func New(
bucketCache: bucketCache,
liveDataRefresh: 5 * time.Second,
builderConfig: builderConfig{
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
logTraceIDWindowPaddingMS: uint64(config.LogTraceIDWindowPadding.Milliseconds()),
},
maxConcurrentQueries: maxConcurrentQueries,
maxConcurrentQueries: config.MaxConcurrentQueries,
config: config,
}
}
func (q *querier) ValidateRequest(req *qbtypes.QueryRangeRequest, opts ...qbtypes.ValidationOption) error {
return req.Validate(append(opts, q.validationOptions()...)...)
}
func (q *querier) validationOptions() []qbtypes.ValidationOption {
return []qbtypes.ValidationOption{qbtypes.WithMaxEndTimeAhead(q.config.MaxEndTimeAhead)}
}
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
// Coerce the window to epoch milliseconds up front so every downstream
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
// safely assume ms regardless of the resolution the caller sent.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
// Callers that build the request in-process never pass through
// UnmarshalJSON, so normalise here too.
req.Start = qbtypes.ToMilliSecs(req.Start)
req.End = qbtypes.ToMilliSecs(req.End)
// In-process callers skip ValidateRequest; the time window still guards the querier.
if err := req.ValidateTimeRange(q.validationOptions()...); err != nil {
return nil, err
}
event := &qbtypes.QBEvent{
Version: "v5",
@@ -534,10 +552,8 @@ func familyTemporality(temporalities map[string]metrictypes.Temporality, names [
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Coerce the window to epoch milliseconds up front (End may be 0 for the
// open-ended stream, which ToMilliSecs leaves untouched).
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
req.Start = qbtypes.ToMilliSecs(req.Start)
req.End = qbtypes.ToMilliSecs(req.End)
event := &qbtypes.QBEvent{
Version: "v5",

View File

@@ -57,8 +57,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
0, // logTraceIDWindowPadding
0, // maxConcurrentQueries
Config{},
)
req := &qbtypes.QueryRangeRequest{
@@ -130,8 +129,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
0, // logTraceIDWindowPadding
0, // maxConcurrentQueries
Config{},
)
req := &qbtypes.QueryRangeRequest{

View File

@@ -37,6 +37,10 @@ func (m *MockQuerier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req
}
}
func (m *MockQuerier) ValidateRequest(req *qbtypes.QueryRangeRequest, opts ...qbtypes.ValidationOption) error {
return req.Validate(opts...)
}
func (m *MockQuerier) QueryRangePreview(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, opts qbtypes.QueryRangePreviewOptions) (*qbtypes.QueryRangePreviewResponse, error) {
if m.QueryRangePreviewFunc != nil {
return m.QueryRangePreviewFunc(ctx, orgID, req, opts)

View File

@@ -50,8 +50,7 @@ func NewFactory(
traceOperatorStmtBuilder,
bucketCache,
flagger,
cfg.LogTraceIDWindowPadding,
cfg.MaxConcurrentQueries,
cfg,
), nil
},
)

View File

@@ -3718,6 +3718,9 @@ func (aH *APIHandler) getDomainList(w http.ResponseWriter, r *http.Request) {
instrumentationtypes.CodeFunctionName: "getDomainList",
})
// Execute the query using the v5 querier
if err := aH.Signoz.Querier.ValidateRequest(queryRangeRequest); err != nil {
aH.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
result, err := aH.Signoz.Querier.QueryRange(ctx, orgID, queryRangeRequest)
if err != nil {
aH.logger.ErrorContext(r.Context(), "query execution failed", errors.Attr(err))
@@ -3778,6 +3781,9 @@ func (aH *APIHandler) getDomainInfo(w http.ResponseWriter, r *http.Request) {
instrumentationtypes.CodeFunctionName: "getDomainInfo",
})
// Execute the query using the v5 querier
if err := aH.Signoz.Querier.ValidateRequest(queryRangeRequest); err != nil {
aH.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
result, err := aH.Signoz.Querier.QueryRange(ctx, orgID, queryRangeRequest)
if err != nil {
aH.logger.ErrorContext(r.Context(), "query execution failed", errors.Attr(err))

View File

@@ -114,13 +114,14 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
// Create querier with test values
metadataStore := telemetrymetadata.NewTelemetryMetaStore(providerSettings, telemetryStore, flagger)
schemaMetadataStore := telemetrymetadata.NewSchemaMetadataStore(telemetryStore, 0)
cfg := statementbuilder.Config{}
ctx := context.Background()
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, schemaMetadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, schemaMetadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, schemaMetadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)

View File

@@ -50,8 +50,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
0,
0, // maxConcurrentQueries (0 means default)
querier.Config{},
), metadataStore
}
@@ -85,8 +84,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
5*time.Minute, // logTraceIDWindowPadding
0, // maxConcurrentQueries (0 means default)
querier.Config{LogTraceIDWindowPadding: 5 * time.Minute},
)
}
@@ -104,7 +102,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
metadataStore.KeysMap = keysMap
fl := flaggertest.New(t)
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(context.Background(), providerSettings, statementbuilder.Config{})
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, nil, fl).New(context.Background(), providerSettings, statementbuilder.Config{})
require.NoError(t, err)
return querier.New(
@@ -121,8 +119,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
0,
0, // maxConcurrentQueries (0 means default)
querier.Config{},
)
}
@@ -140,7 +137,7 @@ func prepareQuerierForAITraces(t *testing.T, telemetryStore telemetrystore.Telem
metadataStore.KeysMap = keysMap
fl := flaggertest.New(t)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(context.Background(), providerSettings, statementbuilder.Config{})
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, nil, fl).New(context.Background(), providerSettings, statementbuilder.Config{})
require.NoError(t, err)
return querier.New(
@@ -157,7 +154,6 @@ func prepareQuerierForAITraces(t *testing.T, telemetryStore telemetrystore.Telem
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
0,
0, // maxConcurrentQueries (0 means default)
querier.Config{},
)
}

View File

@@ -138,6 +138,9 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
instrumentationtypes.CodeFunctionName: "buildAndRunQuery",
})
if err := r.querier.ValidateRequest(params); err != nil {
r.logger.WarnContext(ctx, "invalid query range request", errors.Attr(err))
}
v5Result, err := r.querier.QueryRange(ctx, orgID, params)
if err != nil {
return nil, err

View File

@@ -33,28 +33,6 @@ func ToNanoSecs(epoch uint64) uint64 {
return temp * uint64(math.Pow(10, float64(19-count)))
}
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
func ToMilliSecs(epoch uint64) uint64 {
if epoch == 0 {
return 0
}
temp := epoch
count := 0
for epoch != 0 {
epoch /= 10
count++
}
const msDigits = 13
if count < msDigits {
return temp * uint64(math.Pow(10, float64(msDigits-count)))
}
return temp / uint64(math.Pow(10, float64(count-msDigits)))
}
// TODO(srikanthccv): should these be rounded to nearest multiple of 60 instead of 5 if step > 60?
// That would make graph look nice but "nice" but should be less important than the usefulness.
func RecommendedStepInterval(start, end uint64) uint64 {

View File

@@ -60,51 +60,3 @@ func TestToNanoSecs(t *testing.T) {
})
}
}
func TestToMilliSecs(t *testing.T) {
tests := []struct {
name string
epoch uint64
expected uint64
}{
{
name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC",
epoch: 1672531200, // seconds
expected: 1672531200000, // * 10^3
},
{
name: "13-digit Unix timestamp (milliseconds) - already ms",
epoch: 1672531200000,
expected: 1672531200000, // unchanged
},
{
name: "16-digit Unix timestamp (microseconds)",
epoch: 1672531200000000, // microseconds
expected: 1672531200000, // / 10^3
},
{
name: "19-digit Unix timestamp (nanoseconds)",
epoch: 1672531200000000000, // nanoseconds
expected: 1672531200000, // / 10^6
},
{
name: "Unix epoch start - zero is unchanged",
epoch: 0,
expected: 0,
},
{
name: "Recent timestamp in seconds - 2024-05-25 12:00:00 UTC",
epoch: 1716638400,
expected: 1716638400000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToMilliSecs(tt.epoch)
if result != tt.expected {
t.Errorf("ToMilliSecs(%d) = %d, want %d", tt.epoch, result, tt.expected)
}
})
}
}

View File

@@ -65,7 +65,7 @@ func TestNewHandlers(t *testing.T) {
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
require.NoError(t, err)
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), spanMapperRegistry, providerSettings)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, spanMapperModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, spanMapperModule)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -108,6 +108,7 @@ func NewModules(
querier querier.Querier,
telemetryStore telemetrystore.TelemetryStore,
telemetryMetadataStore telemetrytypes.MetadataStore,
schemaMetadataStore telemetrytypes.SchemaMetadataStore,
authNs map[authtypes.AuthNProvider]authn.AuthN,
authz authz.AuthZ,
cache cache.Cache,
@@ -147,11 +148,11 @@ func NewModules(
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
RawDataExport: implrawdataexport.NewModule(querier, providerSettings),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
Services: implservices.NewModule(querier, telemetryStore, providerSettings),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
@@ -161,9 +162,9 @@ func NewModules(
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore, providerSettings.Logger),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, schemaMetadataStore, fl), providerSettings, config.TraceDetail),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, providerSettings),
Tag: tagModule,
}
}

View File

@@ -73,7 +73,7 @@ func TestNewModules(t *testing.T) {
require.NoError(t, err)
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), spanMapperRegistry, providerSettings)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), spanMapperModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), spanMapperModule)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -270,6 +270,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRoutePolicyTuplesFactory(sqlstore),
sqlmigration.NewRenameSavedViewDeprecatedTraceFieldsFactory(),
sqlmigration.NewAddDowntimeTuplesFactory(sqlstore),
sqlmigration.NewAddRuleTuplesFactory(sqlstore),
)
}

View File

@@ -102,7 +102,7 @@ type SigNoz struct {
}
// newQueryStack assembles the query stack once and returns, in order: the shared
// telemetry metadata store (reused elsewhere in signoz.New), the per-signal
// telemetry metadata and schema metadata stores (reused elsewhere in signoz.New), the per-signal
// statement builders (trace, ai-trace, log, audit, metric, meter, trace-operator),
// and the bucket cache. It is the only place that imports the concrete
// statement-builder sub-packages.
@@ -115,6 +115,7 @@ func newQueryStack(
fl flagger.Flagger,
) (
telemetrytypes.MetadataStore,
telemetrytypes.SchemaMetadataStore,
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
qbtypes.StatementBuilder[qbtypes.LogAggregation],
@@ -128,38 +129,39 @@ func newQueryStack(
metadataStore := telemetrymetadata.NewTelemetryMetaStore(settings, telemetryStore, fl)
cfg := config.Querier.Config
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
schemaMetadataStore := telemetrymetadata.NewSchemaMetadataStore(telemetryStore, cfg.SchemaMetadataStore.CacheTTL)
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, schemaMetadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, schemaMetadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, schemaMetadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
auditStmtBuilder, err := auditstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
metricStmtBuilder, err := metricsstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
bucketCache := querier.NewBucketCache(settings, cache, config.Querier.CacheTTL, config.Querier.FluxInterval)
return metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
return metadataStore, schemaMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
}
func New(
@@ -323,7 +325,7 @@ func New(
// Assemble the query stack (metadata store, statement builders, bucket cache) once,
// and reuse the single metadata store everywhere downstream.
telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
telemetryMetadataStore, schemaMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
if err != nil {
return nil, err
}
@@ -535,7 +537,7 @@ func New(
}
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), spanMapperRegistry, providerSettings)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, spanMapperModule)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, schemaMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, spanMapperModule)
// Initialize ruler from the variant-specific provider factories
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")

View File

@@ -0,0 +1,140 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addRuleTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddRuleTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_rule_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addRuleTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addRuleTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addRuleTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
crudVerbs := []string{"create", "read", "update", "delete", "list"}
readVerbs := []string{"read", "list"}
tuples := make([]migrationTuple, 0, 12)
for _, verb := range crudVerbs {
tuples = append(tuples,
migrationTuple{authtypes.SigNozAdminRoleName, "metaresource", "rule", verb},
migrationTuple{authtypes.SigNozEditorRoleName, "metaresource", "rule", verb},
)
}
for _, verb := range readVerbs {
tuples = append(tuples, migrationTuple{authtypes.SigNozViewerRoleName, "metaresource", "rule", verb})
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addRuleTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -17,9 +17,10 @@ import (
func NewFactory(
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
schemaMetadataStore telemetrytypes.SchemaMetadataStore,
fl flagger.Flagger,
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
return scopedtraces.NewFactory(factory.MustNewName("ai"), Scope(), telemetryStore, metadataStore, fl)
return scopedtraces.NewFactory(factory.MustNewName("ai"), Scope(), telemetryStore, metadataStore, schemaMetadataStore, fl)
}
// Scope describes gen_ai for the scoped trace builder: an AI trace has >=1 gen_ai

View File

@@ -107,7 +107,7 @@ func newTestBuilderWithKeys(t *testing.T, keysMap map[string][]*telemetrytypes.T
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = keysMap
stmtBuilder, err := NewFactory(nil, mockMetadataStore, flaggertest.New(t)).
stmtBuilder, err := NewFactory(nil, mockMetadataStore, nil, flaggertest.New(t)).
New(context.Background(), settings, statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Threshold: 100000}})
require.NoError(t, err)
return stmtBuilder

View File

@@ -1,6 +1,8 @@
package statementbuilder
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
)
@@ -11,6 +13,12 @@ type SkipResourceFingerprint struct {
Threshold uint64 `yaml:"threshold" mapstructure:"threshold"`
}
// SchemaMetadataStore configures the schema metadata store.
type SchemaMetadataStore struct {
// CacheTTL is how long found entries stay cached; 0 caches them for the life of the process.
CacheTTL time.Duration `yaml:"cache_ttl" mapstructure:"cache_ttl"`
}
// Config represents the configuration for the statement builders.
type Config struct {
// SkipResourceFingerprint configures when the resource fingerprint subquery is skipped in favor of main-table filtering.
@@ -21,6 +29,8 @@ type Config struct {
// SearchMaxScanRowsJSONBody is the same budget for body_v2, where each row costs far
// more: toString() rebuilds every document and no skip index prunes (0 disables).
SearchMaxScanRowsJSONBody int64 `yaml:"search_max_scan_rows_json_body" mapstructure:"search_max_scan_rows_json_body"`
// SchemaMetadataStore configures the schema metadata store.
SchemaMetadataStore SchemaMetadataStore `yaml:"schemametadatastore" mapstructure:"schemametadatastore"`
}
func NewConfig() Config {
@@ -45,5 +55,8 @@ func (c Config) Validate() error {
if c.SearchMaxScanRowsJSONBody < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "search_max_scan_rows_json_body must not be negative, got %v", c.SearchMaxScanRowsJSONBody)
}
if c.SchemaMetadataStore.CacheTTL < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "schemametadatastore.cache_ttl must not be negative, got %v", c.SchemaMetadataStore.CacheTTL)
}
return nil
}

View File

@@ -50,16 +50,17 @@ func NewFactory(
scope TraceScope,
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
schemaMetadataStore telemetrytypes.SchemaMetadataStore,
fl flagger.Flagger,
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
return factory.NewProviderFactory(
name,
func(ctx context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, schemaMetadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, err
}
return NewScopedTraceStatementBuilder(settings, metadataStore, tracestelemetryschema.NewStorage(), scope, traceStmtBuilder, fl), nil
return NewScopedTraceStatementBuilder(settings, metadataStore, tracestelemetryschema.NewStorage(schemaMetadataStore), scope, traceStmtBuilder, fl), nil
},
)
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -26,7 +27,9 @@ var jsonAttrColRe = regexp.MustCompile(`,\s*attributes\s*(,| FROM )`)
func newBulkTestBuilder(t *testing.T, releaseTime time.Time) *traceQueryStatementBuilder {
t.Helper()
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureUseTraceAttributesJSON.String(): true})
storage := tracestelemetryschema.NewStorage()
schemaMetadataStore := telemetrytypestest.NewMockSchemaMetadataStore(t)
schemaMetadataStore.EXPECT().GetBaseColumnEvolutions(mock.Anything, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute).Return(tracestelemetryschema.MockAttributeEvolutionData(releaseTime), nil).Maybe()
storage := tracestelemetryschema.NewStorage(schemaMetadataStore)
store := telemetrytypestest.NewMockMetadataStore()
store.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
store.KeysMap["http.route"] = []*telemetrytypes.TelemetryFieldKey{{
@@ -148,3 +151,153 @@ func TestGroupByAttributeHomeAcrossRollout(t *testing.T) {
})
}
}
func TestUnknownAttributeFollowsRollout(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
rel := releaseTime.UnixMilli()
day := int64(24 * time.Hour / time.Millisecond)
b := newBulkTestBuilder(t, releaseTime)
testCases := []struct {
name string
startMs uint64
endMs uint64
wantContains []string
wantNotContains []string
}{
{
name: "BeforeRollout_ReadsLegacyMap",
startMs: uint64(rel - 2*day),
endMs: uint64(rel - day),
wantContains: []string{"attributes_string['unknown.attr'] = ?", "mapContains(attributes_string, 'unknown.attr')"},
wantNotContains: []string{"attributes.`unknown.attr`"},
},
{
name: "StraddlingRollout_JSONThenLegacyFallback",
startMs: uint64(rel - day),
endMs: uint64(rel + day),
wantContains: []string{"attributes.`unknown.attr` IS NOT NULL", "attributes.`unknown.attr`::String", "attributes_string['unknown.attr']"},
wantNotContains: nil,
},
{
name: "AfterRollout_ReadsJSONOnly",
startMs: uint64(rel + day),
endMs: uint64(rel + 2*day),
wantContains: []string{"attributes.`unknown.attr`::String = ?"},
wantNotContains: []string{"attributes_string"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stmt, err := b.Build(
context.Background(), valuer.UUID{}, testCase.startMs, testCase.endMs,
qbtypes.RequestTypeTimeSeries,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{Expression: "unknown.attr = 'x'"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "unknown.attr",
}}},
Limit: 10,
},
nil,
)
require.NoError(t, err)
for _, want := range testCase.wantContains {
assert.Contains(t, stmt.Query, want)
}
for _, unwanted := range testCase.wantNotContains {
assert.NotContains(t, stmt.Query, unwanted)
}
assert.Contains(t, strings.Join(stmt.Warnings, "\n"), "not found in metadata")
})
}
}
func TestUnknownAttributeFollowsRolloutAfterRelease(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
startMs := uint64(releaseTime.Add(24 * time.Hour).UnixMilli())
endMs := uint64(releaseTime.Add(48 * time.Hour).UnixMilli())
b := newBulkTestBuilder(t, releaseTime)
testCases := []struct {
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
wantContains []string
wantNotContains []string
}{
{
name: "AggregationArgument_ReadsJSONOnly",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(unknown.num)"}},
},
wantContains: []string{"attributes.`unknown.num`"},
wantNotContains: []string{"attributes_number['unknown.num']"},
},
{
name: "RawSelectAndOrder_ReadsJSONOnly",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
SelectFields: []telemetrytypes.TelemetryFieldKey{{Name: "unknown.attr"}},
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "unknown.attr"}}, Direction: qbtypes.OrderDirectionAsc}},
Limit: 10,
},
wantContains: []string{"attributes.`unknown.attr`"},
wantNotContains: []string{"attributes_string['unknown.attr']"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stmt, err := b.Build(context.Background(), valuer.UUID{}, startMs, endMs, testCase.requestType, testCase.query, nil)
require.NoError(t, err)
for _, want := range testCase.wantContains {
assert.Contains(t, stmt.Query, want)
}
for _, unwanted := range testCase.wantNotContains {
assert.NotContains(t, stmt.Query, unwanted)
}
})
}
}
func TestTraceOperatorUnknownAttributeFollowsRollout(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
startMs := uint64(releaseTime.Add(24 * time.Hour).UnixMilli())
endMs := uint64(releaseTime.Add(48 * time.Hour).UnixMilli())
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureUseTraceAttributesJSON.String(): true})
schemaMetadataStore := telemetrytypestest.NewMockSchemaMetadataStore(t)
schemaMetadataStore.EXPECT().GetBaseColumnEvolutions(mock.Anything, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute).Return(tracestelemetryschema.MockAttributeEvolutionData(releaseTime), nil).Maybe()
storage := tracestelemetryschema.NewStorage(schemaMetadataStore)
store := telemetrytypestest.NewMockMetadataStore()
store.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
store.ColumnEvolutionMetadataMap["traces:attribute:__all__"] = tracestelemetryschema.MockAttributeEvolutionData(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
traceStmtBuilder := NewTraceQueryStatementBuilder(instrumentationtest.New().ToProviderSettings(), store, storage, aggExprRewriter, nil, fl, false, 100000)
b := NewTraceOperatorStatementBuilder(instrumentationtest.New().ToProviderSettings(), store, storage, traceStmtBuilder, aggExprRewriter, fl)
operator := qbtypes.QueryBuilderTraceOperator{Expression: "A => B", Limit: 10}
require.NoError(t, operator.ParseExpression())
compositeQuery := &qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{Name: "A", Signal: telemetrytypes.SignalTraces, Filter: &qbtypes.Filter{Expression: "unknown.attr = 'x'"}}},
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{Name: "B", Signal: telemetrytypes.SignalTraces, Filter: &qbtypes.Filter{Expression: "unknown.other = 'y'"}}},
}}
stmt, err := b.Build(context.Background(), valuer.UUID{}, startMs, endMs, qbtypes.RequestTypeRaw, operator, compositeQuery)
require.NoError(t, err)
assert.Contains(t, stmt.Query, "attributes.`unknown.attr`::String = ?")
assert.Contains(t, stmt.Query, "attributes.`unknown.other`::String = ?")
assert.NotContains(t, stmt.Query, "attributes_string['unknown.attr']")
assert.NotContains(t, stmt.Query, "attributes_string['unknown.other']")
}

View File

@@ -49,12 +49,13 @@ var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatement
func NewFactory(
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
schemaMetadataStore telemetrytypes.SchemaMetadataStore,
fl flagger.Flagger,
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
return factory.NewProviderFactory(
factory.MustNewName("traces"),
func(_ context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(schemaMetadataStore)
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage, fl, telemetrytypes.SignalTraces)
return NewTraceQueryStatementBuilder(
settings, metadataStore, storage, aggExprRewriter, telemetryStore, fl,

View File

@@ -465,7 +465,7 @@ func TestStatementBuilder(t *testing.T) {
}
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
@@ -764,7 +764,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
}
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
@@ -1026,7 +1026,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = c.keysMap
if mockMetadataStore.KeysMap == nil {
@@ -1099,7 +1099,7 @@ func TestStatementBuilderGroupByResourceEvolution(t *testing.T) {
}
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
@@ -1264,7 +1264,7 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
}
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
@@ -1900,7 +1900,7 @@ func newSkipResourceFingerprintBuilder(
t.Helper()
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
@@ -1926,7 +1926,7 @@ func TestStatementBuilderGroupByUnseenKey(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
@@ -1966,7 +1966,7 @@ func TestStatementBuilderAggregationUnseenKey(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
@@ -2039,7 +2039,7 @@ func TestStatementBuilderSemconvFamilies(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): c.flag,
})
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
@@ -2071,7 +2071,7 @@ func TestStatementBuilderSemconvSingleSpelling(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment": {{

View File

@@ -20,7 +20,7 @@ func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementB
t.Helper()
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)

View File

@@ -36,12 +36,13 @@ var _ qbtypes.TraceOperatorStatementBuilder = (*traceOperatorStatementBuilder)(n
func NewOperatorFactory(
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
schemaMetadataStore telemetrytypes.SchemaMetadataStore,
fl flagger.Flagger,
) factory.ProviderFactory[qbtypes.TraceOperatorStatementBuilder, statementbuilder.Config] {
return factory.NewProviderFactory(
factory.MustNewName("traceoperator"),
func(_ context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.TraceOperatorStatementBuilder, error) {
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(schemaMetadataStore)
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage, fl, telemetrytypes.SignalTraces)
traceStmtBuilder := NewTraceQueryStatementBuilder(
settings, metadataStore, storage, aggExprRewriter, telemetryStore, fl,

View File

@@ -22,7 +22,7 @@ func TestTraceTimeRangeOptimization(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fl := flaggertest.New(t)
storage := tracestelemetryschema.NewStorage()
storage := tracestelemetryschema.NewStorage(nil)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)

View File

@@ -2442,10 +2442,10 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
return temporalities, types, nil
}
func (k *telemetryMetaStore) fetchEvolutionEntryFromClickHouse(ctx context.Context, selectors []*telemetrytypes.EvolutionSelector) ([]*telemetrytypes.EvolutionEntry, error) {
func fetchEvolutionEntryFromClickHouse(ctx context.Context, telemetryStore telemetrystore.TelemetryStore, table string, selectors []*telemetrytypes.EvolutionSelector) ([]*telemetrytypes.EvolutionEntry, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("signal", "column_name", "column_type", "field_context", "field_name", "version", "release_time")
sb.From(fmt.Sprintf("%s.%s", k.relatedMetadataDBName, k.columnEvolutionMetadataTblName))
sb.From(table)
sb.OrderBy("release_time ASC")
var clauses []string
@@ -2467,7 +2467,7 @@ func (k *telemetryMetaStore) fetchEvolutionEntryFromClickHouse(ctx context.Conte
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var entries []*telemetrytypes.EvolutionEntry
rows, err := k.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
rows, err := telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -2517,7 +2517,7 @@ func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Co
metadataKeySelectors = append(metadataKeySelectors, selector)
}
evolutions, err := k.fetchEvolutionEntryFromClickHouse(ctx, metadataKeySelectors)
evolutions, err := fetchEvolutionEntryFromClickHouse(ctx, k.telemetrystore, fmt.Sprintf("%s.%s", k.relatedMetadataDBName, k.columnEvolutionMetadataTblName), metadataKeySelectors)
if err != nil {
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to fetch evolution from clickhouse %s", err.Error())
}

View File

@@ -0,0 +1,49 @@
package telemetrymetadata
import (
"context"
"fmt"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
var CodeFailLoadBaseColumnEvolutions = errors.MustNewCode("fail_load_base_column_evolutions")
type schemaMetadataStore struct {
telemetryStore telemetrystore.TelemetryStore
cacheTTL time.Duration
baseColumnEvolutions sync.Map
}
type cachedEvolutions struct {
entries []*telemetrytypes.EvolutionEntry
fetchedAt time.Time
}
// A zero cacheTTL caches for the life of the process.
func NewSchemaMetadataStore(telemetryStore telemetrystore.TelemetryStore, cacheTTL time.Duration) telemetrytypes.SchemaMetadataStore {
return &schemaMetadataStore{telemetryStore: telemetryStore, cacheTTL: cacheTTL}
}
// An empty result is not cached: the migrator can add the entries at runtime.
func (s *schemaMetadataStore) GetBaseColumnEvolutions(ctx context.Context, signal telemetrytypes.Signal, fieldContext telemetrytypes.FieldContext) ([]*telemetrytypes.EvolutionEntry, error) {
selector := &telemetrytypes.EvolutionSelector{Signal: signal, FieldContext: fieldContext, FieldName: telemetrytypes.EvolutionFieldNameAll}
if cached, ok := s.baseColumnEvolutions.Load(selector.QualifiedName()); ok {
if cached := cached.(cachedEvolutions); s.cacheTTL == 0 || time.Since(cached.fetchedAt) < s.cacheTTL {
return cached.entries, nil
}
}
entries, err := fetchEvolutionEntryFromClickHouse(ctx, s.telemetryStore, fmt.Sprintf("%s.%s", DBName, ColumnEvolutionMetadataTableName), []*telemetrytypes.EvolutionSelector{selector})
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadBaseColumnEvolutions, "failed to fetch the base column evolutions of %s", selector.QualifiedName())
}
if len(entries) > 0 {
s.baseColumnEvolutions.Store(selector.QualifiedName(), cachedEvolutions{entries: entries, fetchedAt: time.Now()})
}
return entries, nil
}

View File

@@ -0,0 +1,88 @@
package telemetrymetadata
import (
"context"
"testing"
"time"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
var evolutionColumns = []cmock.ColumnType{
{Name: "signal", Type: "String"},
{Name: "column_name", Type: "String"},
{Name: "column_type", Type: "String"},
{Name: "field_context", Type: "String"},
{Name: "field_name", Type: "String"},
{Name: "version", Type: "UInt32"},
{Name: "release_time", Type: "Float64"},
}
func expectBaseColumnEvolutionsQuery(mock cmock.ClickConnMockCommon, rows [][]any) {
mock.ExpectQuery(`SELECT signal, column_name, column_type, field_context, field_name, version, release_time FROM signoz_metadata\.distributed_column_evolution_metadata`).
WithArgs(telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute, "__all__", "__all__").
WillReturnRows(cmock.NewRows(evolutionColumns, rows))
}
func TestGetBaseColumnEvolutionsReadsCache(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
store := NewSchemaMetadataStore(mockTelemetryStore, 0)
releaseTime := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
expectBaseColumnEvolutionsQuery(mock, [][]any{
{"traces", "attributes", "JSON()", "attribute", "__all__", uint32(0), float64(releaseTime.UnixNano())},
})
for range 2 {
entries, err := store.GetBaseColumnEvolutions(context.Background(), telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "attributes", entries[0].ColumnName)
assert.Equal(t, releaseTime, entries[0].ReleaseTime.UTC())
}
require.NoError(t, mock.ExpectationsWereMet())
}
func TestGetBaseColumnEvolutionsRefetchesEmptyResult(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
store := NewSchemaMetadataStore(mockTelemetryStore, 0)
get := func() []*telemetrytypes.EvolutionEntry {
entries, err := store.GetBaseColumnEvolutions(context.Background(), telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute)
require.NoError(t, err)
return entries
}
expectBaseColumnEvolutionsQuery(mock, nil)
assert.Empty(t, get())
expectBaseColumnEvolutionsQuery(mock, [][]any{
{"traces", "attributes", "JSON()", "attribute", "__all__", uint32(0), float64(time.Now().UnixNano())},
})
assert.Len(t, get(), 1)
assert.Len(t, get(), 1, "a found result is cached")
require.NoError(t, mock.ExpectationsWereMet())
}
func TestGetBaseColumnEvolutionsRefetchesExpiredEntry(t *testing.T) {
mockTelemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &regexMatcher{})
mock := mockTelemetryStore.Mock()
store := NewSchemaMetadataStore(mockTelemetryStore, time.Nanosecond)
rows := [][]any{{"traces", "attributes", "JSON()", "attribute", "__all__", uint32(0), float64(time.Now().UnixNano())}}
expectBaseColumnEvolutionsQuery(mock, rows)
expectBaseColumnEvolutionsQuery(mock, rows)
for range 2 {
entries, err := store.GetBaseColumnEvolutions(context.Background(), telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute)
require.NoError(t, err)
assert.Len(t, entries, 1)
}
require.NoError(t, mock.ExpectationsWereMet())
}

View File

@@ -288,7 +288,7 @@ func TestConditionFor(t *testing.T) {
},
}
storage := NewStorage()
storage := NewStorage(nil)
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
@@ -374,7 +374,7 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
},
}
storage := NewStorage()
storage := NewStorage(nil)
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
@@ -396,7 +396,7 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
// undeclared (Dynamic) paths and genuinely NULL when absent.
func TestConditionForScopeIntrinsicFields(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
testCases := []struct {
name string
@@ -482,7 +482,7 @@ func TestConditionForScopeIntrinsicFields(t *testing.T) {
// user input and queries anyway, emitting a warning instead of failing.
func TestConditionForSynthesizedKeys(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
// no metadata matches -> the builder must synthesize from user input
var noMatches map[string][]*telemetrytypes.TelemetryFieldKey
@@ -692,7 +692,7 @@ func TestConditionForSynthesizedKeys(t *testing.T) {
// attribute, with or without a metadata entry for the column.
func TestIntrinsicColumnFirst(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
q := qbtypes.QueryInfo{StartNs: 1761437108000000000, EndNs: 1761458708000000000}
attr := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}

View File

@@ -51,7 +51,7 @@ func TestConditionForFamilyMergesMembersCurrentFirst(t *testing.T) {
}},
}
fl := familyFlagOn(t)
storage := NewStorage()
storage := NewStorage(nil)
// The requested spelling is the old name; precedence must still be
// current-first.
@@ -87,7 +87,7 @@ func TestConditionForFamilyNegativeKeepsKeylessRows(t *testing.T) {
}},
}
fl := familyFlagOn(t)
storage := NewStorage()
storage := NewStorage(nil)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := querybuilder.Conditions(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, qbtypes.FilterOperatorNotEqual, "production", fieldKeys, false, sb)
@@ -122,7 +122,7 @@ func TestConditionForFamilyExists(t *testing.T) {
}},
}
fl := familyFlagOn(t)
storage := NewStorage()
storage := NewStorage(nil)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := querybuilder.Conditions(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, qbtypes.FilterOperatorNotExists, nil, fieldKeys, false, sb)
@@ -157,7 +157,7 @@ func TestConditionForFamilyOffByDefault(t *testing.T) {
}},
}
fl := flaggertest.New(t)
storage := NewStorage()
storage := NewStorage(nil)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := querybuilder.Conditions(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, qbtypes.FilterOperatorEqual, "production", fieldKeys, false, sb)
@@ -193,7 +193,7 @@ func TestConditionForSingleMemberIsUnchanged(t *testing.T) {
}
delete(fieldKeys, "deployment.environment")
fl := familyFlagOn(t)
storage := NewStorage()
storage := NewStorage(nil)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := querybuilder.Conditions(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, qbtypes.FilterOperatorEqual, "production", fieldKeys, false, sb)
@@ -226,7 +226,7 @@ func TestColumnExpressionForFamilyGroupBy(t *testing.T) {
}},
}
fl := familyFlagOn(t)
storage := NewStorage()
storage := NewStorage(nil)
expr, err := querybuilder.ResolveColumn(context.Background(), querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, startNs, endNs), storage, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
require.NoError(t, err)

View File

@@ -15,7 +15,7 @@ import (
func TestSpanScopeFilterExpression(t *testing.T) {
// Test that span scope fields work in filter expressions
storage := NewStorage()
storage := NewStorage(nil)
tests := []struct {
name string
@@ -125,7 +125,7 @@ func TestSpanScopeWithResourceFilter(t *testing.T) {
// For now, just verify the expression parses correctly
// In a real implementation, we'd need to check that the resource filter
// is properly skipped when span scope fields are present
storage := NewStorage()
storage := NewStorage(nil)
// Prepare field keys for the test
fieldKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey)

View File

@@ -162,12 +162,14 @@ var (
}
)
type storage struct{}
type storage struct {
schemaMetadataStore telemetrytypes.SchemaMetadataStore
}
var _ qbtypes.Storage = (*storage)(nil)
func NewStorage() qbtypes.Storage {
return &storage{}
func NewStorage(schemaMetadataStore telemetrytypes.SchemaMetadataStore) qbtypes.Storage {
return &storage{schemaMetadataStore: schemaMetadataStore}
}
func (m *storage) getColumn(
@@ -562,9 +564,35 @@ func (m *storage) Fallback(ctx context.Context, q qbtypes.QueryInfo, key *teleme
literal := telemetrytypes.NewTelemetryFieldKey(key.FieldContext.StringValue()+"."+key.Name, key.FieldContext, key.FieldDataType)
keys = append(querybuilder.SynthesizeKeys(key, value), querybuilder.SynthesizeKeys(literal, value)...)
}
if err := m.addBaseColumnEvolutions(ctx, q, keys); err != nil {
return nil, err
}
return querybuilder.WrapAsLogicalFields(key.Name, keys), nil
}
func (m *storage) addBaseColumnEvolutions(ctx context.Context, q qbtypes.QueryInfo, keys []*telemetrytypes.TelemetryFieldKey) error {
if !q.TraceAttrsJSONOn || m.schemaMetadataStore == nil {
return nil
}
var attributeKeys []*telemetrytypes.TelemetryFieldKey
for _, key := range keys {
if key.FieldContext == telemetrytypes.FieldContextAttribute && len(key.Evolutions) == 0 {
attributeKeys = append(attributeKeys, key)
}
}
if len(attributeKeys) == 0 {
return nil
}
evolutions, err := m.schemaMetadataStore.GetBaseColumnEvolutions(ctx, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute)
if err != nil {
return err
}
for _, key := range attributeKeys {
key.Evolutions = evolutions
}
return nil
}
// stampColumnType gives an untyped column key the data type its column reads
// as, so the intrinsic-column step can compare it with same-named metadata.
func stampColumnType(key *telemetrytypes.TelemetryFieldKey, columns []*schema.Column) *telemetrytypes.TelemetryFieldKey {

View File

@@ -8,8 +8,10 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -44,7 +46,7 @@ func attrKey(name string, dt telemetrytypes.FieldDataType, evo []*telemetrytypes
// cast, straddling a dual-read multiIf with the JSON column first.
func TestFieldForAttributeJSONEvolution(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
testCases := []struct {
@@ -79,7 +81,7 @@ func TestFieldForAttributeJSONEvolution(t *testing.T) {
// entry is registered: a key with no evolutions resolves to the Map column for every window.
func TestFieldForAttributeNoEvolutionParity(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
for _, dt := range []struct {
dataType telemetrytypes.FieldDataType
@@ -101,7 +103,7 @@ func TestFieldForAttributeNoEvolutionParity(t *testing.T) {
// every window.
func TestAttributeJSONFlagOffParity(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
windows := map[string][2]uint64{
"BeforeRelease": attrWindowBefore,
@@ -154,7 +156,7 @@ func TestAttributeJSONFlagOffParity(t *testing.T) {
// numeric comparisons keep numeric semantics; existence never tests the ::String cast.
func TestConditionForAttributeJSON(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
testCases := []struct {
@@ -261,7 +263,7 @@ func TestConditionForAttributeJSON(t *testing.T) {
// home is excluded (De Morgan), including rows that predate the JSON column.
func TestConditionForAttributeJSONNotExistsDualRead(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
@@ -281,7 +283,7 @@ func TestConditionForAttributeJSONNotExistsDualRead(t *testing.T) {
// path is NULL rather than a spurious ”/0, and the numeric branch keeps its toFloat64 coercion.
func TestColumnExpressionForAttributeJSON(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
t.Run("group by string", func(t *testing.T) {
@@ -304,7 +306,7 @@ func TestColumnExpressionForAttributeJSON(t *testing.T) {
// attribute filter must not emit the "ambiguous key" warning.
func TestAttributeJSONNoAmbiguityWarning(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
key := attrKey("user.id", telemetrytypes.FieldDataTypeString, evo)
@@ -320,7 +322,7 @@ func TestAttributeJSONNoAmbiguityWarning(t *testing.T) {
// warning. In the JSON column the two branches share the raw path; only the cast differs.
func TestConditionForAttributeJSONTypeCollision(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
strKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeString, evo)
@@ -350,7 +352,7 @@ func TestConditionForAttributeJSONTypeCollision(t *testing.T) {
// as its actual stored type.
func TestColumnExpressionForAttributeJSONTypeCollision(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
strKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeString, evo)
@@ -374,7 +376,7 @@ func TestColumnExpressionForAttributeJSONTypeCollision(t *testing.T) {
// coverage.
func TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
numKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeNumber, evo)
@@ -396,7 +398,7 @@ func TestColumnExpressionForAttributeJSONTypeCollisionNumericAgg(t *testing.T) {
// columns (attributes_string / attributes_number), each with its own mapContains guard.
func TestConditionForAttributeMapTypeCollisionParity(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
strKey := attrKey("http.status_code", telemetrytypes.FieldDataTypeString, evo)
@@ -440,7 +442,7 @@ func TestColumnForUnspecifiedAttributeNoBranchFlip(t *testing.T) {
// evolution: a key without it (the pre-rollout system) is byte-identical to today.
func TestConditionForAttributeJSONNegativeOperatorParity(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
build := func(t *testing.T, key telemetrytypes.TelemetryFieldKey, window [2]uint64, op qbtypes.FilterOperator, value any) string {
@@ -520,7 +522,7 @@ func TestConditionForAttributeJSONNegativeOperatorParity(t *testing.T) {
// EXISTS/NOT EXISTS must still exclude a key absent from every home, rather than matching it.
func TestConditionForAttributeJSONStraddleAbsentKeyExclusion(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockAttributeEvolutionData(attrJSONRelease)
build := func(t *testing.T, key telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) string {
@@ -551,3 +553,139 @@ func TestConditionForAttributeJSONStraddleAbsentKeyExclusion(t *testing.T) {
assert.Contains(t, sql, ", NULL) IS NULL")
})
}
func TestConditionForAttributeJSONUnknownKey(t *testing.T) {
evolutions := MockAttributeEvolutionData(attrJSONRelease)
bare := telemetrytypes.TelemetryFieldKey{Name: "unknown.attr"}
testCases := []struct {
name string
window [2]uint64
flagOff bool
evolutions []*telemetrytypes.EvolutionEntry
key telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
wantContains []string
wantNotContains []string
}{
{
name: "BeforeRollout_ReadsMap",
window: attrWindowBefore,
evolutions: evolutions,
key: bare,
operator: qbtypes.FilterOperatorEqual,
value: "x",
wantContains: []string{"attributes_string['unknown.attr'] = ?"},
wantNotContains: []string{"attributes.`unknown.attr`"},
},
{
name: "AfterRollout_ReadsJSON",
window: attrWindowAfter,
evolutions: evolutions,
key: bare,
operator: qbtypes.FilterOperatorEqual,
value: "x",
wantContains: []string{"attributes.`unknown.attr`::String = ?"},
wantNotContains: []string{"attributes_string"},
},
{
name: "StraddlingRollout_ReadsJSONThenMap",
window: attrWindowStraddle,
evolutions: evolutions,
key: bare,
operator: qbtypes.FilterOperatorEqual,
value: "x",
wantContains: []string{"multiIf(attributes.`unknown.attr` IS NOT NULL, attributes.`unknown.attr`::String, mapContains(attributes_string, 'unknown.attr'), attributes_string['unknown.attr'], NULL) = ?"},
},
{
name: "AfterRollout_NumberOperand_ReadsJSON",
window: attrWindowAfter,
evolutions: evolutions,
key: bare,
operator: qbtypes.FilterOperatorEqual,
value: float64(1),
wantContains: []string{"toFloat64OrNull(attributes.`unknown.attr`::String) = ?"},
wantNotContains: []string{"attributes_number"},
},
{
name: "AfterRollout_Exists_ReadsJSONOnly",
window: attrWindowAfter,
evolutions: evolutions,
key: bare,
operator: qbtypes.FilterOperatorExists,
wantContains: []string{"attributes.`unknown.attr` IS NOT NULL"},
wantNotContains: []string{"mapContains"},
},
{
name: "AfterRollout_AttributeContext_ReadsJSONForBothSpellings",
window: attrWindowAfter,
evolutions: evolutions,
key: telemetrytypes.TelemetryFieldKey{Name: "unknown.attr", FieldContext: telemetrytypes.FieldContextAttribute},
operator: qbtypes.FilterOperatorEqual,
value: "x",
wantContains: []string{"attributes.`unknown.attr`::String = ?", "attributes.`attribute.unknown.attr`::String = ?"},
wantNotContains: []string{"attributes_string"},
},
{
name: "AfterRollout_SpanContext_ReadsJSONAttribute",
window: attrWindowAfter,
evolutions: evolutions,
key: telemetrytypes.TelemetryFieldKey{Name: "unknown.attr", FieldContext: telemetrytypes.FieldContextSpan},
operator: qbtypes.FilterOperatorEqual,
value: "x",
wantContains: []string{"attributes.`unknown.attr`::String = ?"},
wantNotContains: []string{"attributes_string"},
},
{
name: "NoEvolutions_ReadsMap",
window: attrWindowAfter,
key: bare,
operator: qbtypes.FilterOperatorEqual,
value: "x",
wantContains: []string{"attributes_string['unknown.attr'] = ?"},
wantNotContains: []string{"attributes.`unknown.attr`"},
},
{
name: "FlagOff_ReadsMap",
window: attrWindowAfter,
flagOff: true,
evolutions: evolutions,
key: bare,
operator: qbtypes.FilterOperatorEqual,
value: "x",
wantContains: []string{"attributes_string['unknown.attr'] = ?"},
wantNotContains: []string{"attributes.`unknown.attr`"},
},
{
name: "ResourceContext_IgnoresAttributeEvolutions",
window: attrWindowAfter,
evolutions: evolutions,
key: telemetrytypes.TelemetryFieldKey{Name: "unknown.attr", FieldContext: telemetrytypes.FieldContextResource},
operator: qbtypes.FilterOperatorEqual,
value: "x",
wantContains: []string{"resource.`unknown.attr`::String", "resources_string['unknown.attr']"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
schemaMetadataStore := telemetrytypestest.NewMockSchemaMetadataStore(t)
schemaMetadataStore.EXPECT().GetBaseColumnEvolutions(mock.Anything, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute).Return(testCase.evolutions, nil).Maybe()
q := qbtypes.QueryInfo{StartNs: testCase.window[0], EndNs: testCase.window[1], TraceAttrsJSONOn: !testCase.flagOff}
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := querybuilder.Conditions(context.Background(), q, NewStorage(schemaMetadataStore), &testCase.key, testCase.operator, testCase.value, map[string][]*telemetrytypes.TelemetryFieldKey{}, false, sb)
require.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
for _, want := range testCase.wantContains {
assert.Contains(t, sql, want)
}
for _, unwanted := range testCase.wantNotContains {
assert.NotContains(t, sql, unwanted)
}
})
}
}

View File

@@ -24,7 +24,7 @@ var (
// `attributes_promoted` alone after promotion — fanning out only across an evolution boundary.
func TestFieldForAttributePromotedEvolution(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockPromotedAttributeEvolutionData("span.operation", promoJSONRelease, promoPromoRelease)
win := func(from, to string) [2]uint64 {
@@ -65,7 +65,7 @@ func TestFieldForAttributePromotedEvolution(t *testing.T) {
// attributes_promoted_paths_tokenbf) — not the attributes column.
func TestConditionForAttributePromoted(t *testing.T) {
ctx := context.Background()
storage := NewStorage()
storage := NewStorage(nil)
evo := MockPromotedAttributeEvolutionData("span.operation", promoJSONRelease, promoPromoRelease)
afterPromo := [2]uint64{
uint64(time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC).UnixNano()),

View File

@@ -146,7 +146,7 @@ func TestGetFieldKeyName(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
storage := NewStorage()
storage := NewStorage(nil)
read, err := storage.Read(ctx, qbtypes.QueryInfo{StartNs: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()), EndNs: uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())}, &tc.key)
if tc.expectedError != nil {
@@ -234,7 +234,7 @@ func TestFieldForResourceWithEvolution(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
storage := NewStorage()
storage := NewStorage(nil)
read, err := storage.Read(ctx, qbtypes.QueryInfo{StartNs: tc.tsStart, EndNs: tc.tsEnd}, &tc.key)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, read.SQL)
@@ -294,7 +294,7 @@ func TestColumnExpressionForTemporalColumn(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
storage := NewStorage()
storage := NewStorage(nil)
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{StartNs: tsStart, EndNs: tsEnd}, storage, &tc.key, tc.requiredDataType, nil)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
@@ -322,7 +322,7 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
},
}
storage := NewStorage()
storage := NewStorage(nil)
t.Run("bare timestamp resolves to the intrinsic column alone", func(t *testing.T) {
bare := telemetrytypes.TelemetryFieldKey{Name: "timestamp"}
@@ -431,7 +431,7 @@ func TestColumnExpressionForScopeDeclaredPath(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
storage := NewStorage()
storage := NewStorage(nil)
result, err := querybuilder.ResolveColumn(ctx, qbtypes.QueryInfo{}, storage, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)

View File

@@ -706,6 +706,12 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Copy the decoded values back to the original struct
*r = QueryRangeRequest(temp)
// Coerce the window to epoch milliseconds up front so every downstream
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
// safely assume ms regardless of the resolution the caller sent.
r.Start = ToMilliSecs(r.Start)
r.End = ToMilliSecs(r.End)
return nil
}

View File

@@ -0,0 +1,25 @@
package querybuildertypesv5
import "math"
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
func ToMilliSecs(epoch uint64) uint64 {
if epoch == 0 {
return 0
}
temp := epoch
count := 0
for epoch != 0 {
epoch /= 10
count++
}
const msDigits = 13
if count < msDigits {
return temp * uint64(math.Pow(10, float64(msDigits-count)))
}
return temp / uint64(math.Pow(10, float64(count-msDigits)))
}

View File

@@ -0,0 +1,52 @@
package querybuildertypesv5
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestToMilliSecs(t *testing.T) {
testCases := []struct {
name string
epoch uint64
expected uint64
}{
{
name: "SecondsEpoch_TenDigits_ScaledUp",
epoch: 1672531200, // seconds, 2023-01-01 00:00:00 UTC
expected: 1672531200000, // * 10^3
},
{
name: "MilliSecondsEpoch_ThirteenDigits_Unchanged",
epoch: 1672531200000,
expected: 1672531200000, // unchanged
},
{
name: "MicroSecondsEpoch_SixteenDigits_ScaledDown",
epoch: 1672531200000000, // microseconds
expected: 1672531200000, // / 10^3
},
{
name: "NanoSecondsEpoch_NineteenDigits_ScaledDown",
epoch: 1672531200000000000, // nanoseconds
expected: 1672531200000, // / 10^6
},
{
name: "Zero_Unchanged",
epoch: 0,
expected: 0,
},
{
name: "SecondsEpoch_RecentTimestamp_ScaledUp",
epoch: 1716638400, // 2024-05-25 12:00:00 UTC
expected: 1716638400000,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, ToMilliSecs(testCase.epoch))
})
}
}

View File

@@ -5,6 +5,7 @@ import (
"math"
"slices"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
@@ -70,6 +71,9 @@ const (
MaxNumBuckets = 512
)
// DefaultMaxEndTimeAhead tolerates client clock skew; an end time further ahead is a malformed timestamp.
const DefaultMaxEndTimeAhead = 24 * time.Hour
// ValidationOption is a functional option for configuring validation behaviour.
type ValidationOption func(*validationConfig)
@@ -82,6 +86,7 @@ type validationConfig struct {
skipGroupByValidation bool
withTimestampGroupByValidation bool
withReduceToValidation bool
maxEndTimeAhead time.Duration
}
func applyValidationOptions(opts []ValidationOption) validationConfig {
@@ -156,6 +161,13 @@ func WithReduceToValidation() ValidationOption {
}
}
// WithMaxEndTimeAhead sets how far past now a request's end time may be.
func WithMaxEndTimeAhead(d time.Duration) ValidationOption {
return func(cfg *validationConfig) {
cfg.maxEndTimeAhead = d
}
}
// Validate performs preliminary validation on QueryBuilderQuery.
func (q *QueryBuilderQuery[T]) Validate(opts ...ValidationOption) error {
cfg := applyValidationOptions(opts)
@@ -574,12 +586,8 @@ func (q *QueryBuilderQuery[T]) validateOrderByForAggregation() error {
// Validate validates the entire query range request.
func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
// Validate time range
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"start time must be before end time",
)
if err := r.ValidateTimeRange(opts...); err != nil {
return err
}
// Validate request type
@@ -630,15 +638,14 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
// specs) and returns the request type's ValidationOptions. The dry-run path uses
// this so per-query errors can be attributed individually via QueryEnvelope.Validate
// instead of failing fast like Validate does.
func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
func (r *QueryRangeRequest) ValidateRequestScope(opts ...ValidationOption) ([]ValidationOption, error) {
if err := r.ValidateTimeRange(opts...); err != nil {
return nil, err
}
var opts []ValidationOption
switch r.RequestType {
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
opts = GetValidationOptions(r.RequestType)
opts = append(opts, GetValidationOptions(r.RequestType)...)
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
@@ -699,6 +706,28 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
return validateQueryEnvelope(e, opts...)
}
// ValidateTimeRange checks only the request's time window.
func (r *QueryRangeRequest) ValidateTimeRange(opts ...ValidationOption) error {
cfg := applyValidationOptions(opts)
if r.RequestType == RequestTypeRawStream {
return nil
}
if r.Start == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start time is required")
}
if r.Start >= r.End {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
}
maxAhead := cfg.maxEndTimeAhead
if maxAhead <= 0 {
maxAhead = DefaultMaxEndTimeAhead
}
if r.End > uint64(time.Now().Add(maxAhead).UnixMilli()) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "end time %s is too far in the future", time.UnixMilli(int64(r.End)).UTC().Format(time.RFC3339))
}
return nil
}
// validateAllQueriesNotDisabled validates that at least one query in the composite query is enabled.
func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
for _, envelope := range r.CompositeQuery.Queries {

View File

@@ -4,6 +4,8 @@ import (
"time"
)
const EvolutionFieldNameAll = "__all__"
type EvolutionEntry struct {
Signal Signal `json:"signal"`
ColumnName string `json:"column_name"`

View File

@@ -7,6 +7,10 @@ import (
"github.com/SigNoz/signoz/pkg/valuer"
)
type SchemaMetadataStore interface {
GetBaseColumnEvolutions(ctx context.Context, signal Signal, fieldContext FieldContext) ([]*EvolutionEntry, error)
}
// MetadataStore is the interface for the telemetry metadata store.
type MetadataStore interface {
// GetKeys returns a map of field keys types.TelemetryFieldKey by name, there can be multiple keys with the same name

View File

@@ -0,0 +1,122 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package telemetrytypestest
import (
"context"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
mock "github.com/stretchr/testify/mock"
)
// NewMockSchemaMetadataStore creates a new instance of MockSchemaMetadataStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockSchemaMetadataStore(t interface {
mock.TestingT
Cleanup(func())
}) *MockSchemaMetadataStore {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock := &MockSchemaMetadataStore{}
mock.Mock.Test(t)
t.Cleanup(func() {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock.AssertExpectations(t)
})
return mock
}
// MockSchemaMetadataStore is an autogenerated mock type for the SchemaMetadataStore type
type MockSchemaMetadataStore struct {
mock.Mock
}
type MockSchemaMetadataStore_Expecter struct {
mock *mock.Mock
}
func (_m *MockSchemaMetadataStore) EXPECT() *MockSchemaMetadataStore_Expecter {
return &MockSchemaMetadataStore_Expecter{mock: &_m.Mock}
}
// GetBaseColumnEvolutions provides a mock function for the type MockSchemaMetadataStore
func (_mock *MockSchemaMetadataStore) GetBaseColumnEvolutions(ctx context.Context, signal telemetrytypes.Signal, fieldContext telemetrytypes.FieldContext) ([]*telemetrytypes.EvolutionEntry, error) {
ret := _mock.Called(ctx, signal, fieldContext)
if len(ret) == 0 {
panic("no return value specified for GetBaseColumnEvolutions")
}
var r0 []*telemetrytypes.EvolutionEntry
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, telemetrytypes.Signal, telemetrytypes.FieldContext) ([]*telemetrytypes.EvolutionEntry, error)); ok {
return returnFunc(ctx, signal, fieldContext)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, telemetrytypes.Signal, telemetrytypes.FieldContext) []*telemetrytypes.EvolutionEntry); ok {
r0 = returnFunc(ctx, signal, fieldContext)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*telemetrytypes.EvolutionEntry)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, telemetrytypes.Signal, telemetrytypes.FieldContext) error); ok {
r1 = returnFunc(ctx, signal, fieldContext)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockSchemaMetadataStore_GetBaseColumnEvolutions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetBaseColumnEvolutions'
type MockSchemaMetadataStore_GetBaseColumnEvolutions_Call struct {
*mock.Call
}
// GetBaseColumnEvolutions is a helper method to define mock.On call
// - ctx context.Context
// - signal telemetrytypes.Signal
// - fieldContext telemetrytypes.FieldContext
func (_e *MockSchemaMetadataStore_Expecter) GetBaseColumnEvolutions(ctx any, signal any, fieldContext any) *MockSchemaMetadataStore_GetBaseColumnEvolutions_Call {
return &MockSchemaMetadataStore_GetBaseColumnEvolutions_Call{Call: _e.mock.On("GetBaseColumnEvolutions", ctx, signal, fieldContext)}
}
func (_c *MockSchemaMetadataStore_GetBaseColumnEvolutions_Call) Run(run func(ctx context.Context, signal telemetrytypes.Signal, fieldContext telemetrytypes.FieldContext)) *MockSchemaMetadataStore_GetBaseColumnEvolutions_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 telemetrytypes.Signal
if args[1] != nil {
arg1 = args[1].(telemetrytypes.Signal)
}
var arg2 telemetrytypes.FieldContext
if args[2] != nil {
arg2 = args[2].(telemetrytypes.FieldContext)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *MockSchemaMetadataStore_GetBaseColumnEvolutions_Call) Return(evolutionEntrys []*telemetrytypes.EvolutionEntry, err error) *MockSchemaMetadataStore_GetBaseColumnEvolutions_Call {
_c.Call.Return(evolutionEntrys, err)
return _c
}
func (_c *MockSchemaMetadataStore_GetBaseColumnEvolutions_Call) RunAndReturn(run func(ctx context.Context, signal telemetrytypes.Signal, fieldContext telemetrytypes.FieldContext) ([]*telemetrytypes.EvolutionEntry, error)) *MockSchemaMetadataStore_GetBaseColumnEvolutions_Call {
_c.Call.Return(run)
return _c
}

View File

@@ -92,6 +92,8 @@ def create_signoz(
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__WAIT": "1s",
"SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_GROUP__INTERVAL": "5s",
"SIGNOZ_CLOUDINTEGRATION_AGENT_VERSION": "v0.0.8",
# tests reseed the attributes rollout row between cases
"SIGNOZ_QUERIER_SCHEMAMETADATASTORE_CACHE__TTL": "1ns",
}
| sqlstore.env
| clickhouse.env

View File

@@ -0,0 +1,165 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.metrics import Metrics
from fixtures.querier import (
Aggregation,
BuilderQuery,
MetricAggregation,
get_all_warnings,
get_error_message,
get_series_values,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces
TWO_YEARS = timedelta(days=730)
@pytest.mark.parametrize(
"start_ms,end_ms,message",
[
pytest.param(0, 1_700_000_000_000, "start time is required", id="zero_start"),
pytest.param(1_700_000_000_000, 1_700_000_000_000, "start time must be before end time", id="start_equals_end"),
# 2592000000 is 30 days in ms; read as an epoch in seconds it is year 2052.
pytest.param(1_700_000_000_000, 2_592_000_000, "end time 2052-02-20T00:00:00Z is too far in the future", id="end_in_2052"),
],
)
def test_time_range_rejected(
start_ms: int,
end_ms: int,
message: str,
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
queries=[BuilderQuery(signal="logs", aggregations=[Aggregation(expression="count()")], step_interval=60).to_dict()],
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert get_error_message(response.json()) == message
def test_two_year_logs_timeseries_step_clamped(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
# A 1s step over 2 years is clamped to 42045s (at most ~1500 points); end is aligned to it so the bucket count is stable.
step_seconds = 42045
end = datetime.fromtimestamp(int(datetime.now(tz=UTC).timestamp()) // step_seconds * step_seconds, tz=UTC)
insert_logs([Logs(timestamp=end - timedelta(minutes=1), resources={"service.name": "time-range-logs"}, body="two year span")])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((end - TWO_YEARS).timestamp() * 1000),
end_ms=int(end.timestamp() * 1000),
queries=[
BuilderQuery(
signal="logs",
filter_expression="service.name = 'time-range-logs'",
aggregations=[Aggregation(expression="count()")],
step_interval=1,
).to_dict()
],
format_options={"formatTableResultForUI": False, "fillGaps": True},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["meta"]["stepIntervals"] == {"A": step_seconds}
assert get_all_warnings(response.json()) == [
{"message": f"Query A is requesting aggregation interval 1 seconds, which is smaller than the minimum allowed interval of {step_seconds} seconds for selected time range. Using the minimum instead"},
]
values = get_series_values(response.json(), "A")
assert len(values) == 1502
assert sum(v["value"] for v in values) == 1
def test_two_year_traces_timeseries_step_clamped(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
# Same clamp as logs: 42045s; end is aligned to it so the bucket count is stable.
step_seconds = 42045
end = datetime.fromtimestamp(int(datetime.now(tz=UTC).timestamp()) // step_seconds * step_seconds, tz=UTC)
insert_traces([Traces(timestamp=end - timedelta(minutes=1), trace_id=TraceIdGenerator.trace_id(), span_id=TraceIdGenerator.span_id(), resources={"service.name": "time-range-traces"})])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((end - TWO_YEARS).timestamp() * 1000),
end_ms=int(end.timestamp() * 1000),
queries=[
BuilderQuery(
signal="traces",
filter_expression="service.name = 'time-range-traces'",
aggregations=[Aggregation(expression="count()")],
step_interval=1,
).to_dict()
],
format_options={"formatTableResultForUI": False, "fillGaps": True},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["meta"]["stepIntervals"] == {"A": step_seconds}
assert get_all_warnings(response.json()) == [
{"message": f"Query A is requesting aggregation interval 1 seconds, which is smaller than the minimum allowed interval of {step_seconds} seconds for selected time range. Using the minimum instead"},
]
values = get_series_values(response.json(), "A")
assert len(values) == 1502
assert sum(v["value"] for v in values) == 1
def test_two_year_metrics_timeseries_step_clamped(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
# A 1s step over 2 years is clamped to 210600s (~300 points); end is aligned to it so the bucket count is stable.
step_seconds = 210600
metric_name = "time_range_two_year_gauge"
end = datetime.fromtimestamp(int(datetime.now(tz=UTC).timestamp()) // step_seconds * step_seconds, tz=UTC)
insert_metrics([Metrics(metric_name=metric_name, labels={"service": "time-range-metrics"}, timestamp=end - timedelta(minutes=1), value=1.0, temporality="Unspecified", type_="Gauge", is_monotonic=False)])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((end - TWO_YEARS).timestamp() * 1000),
end_ms=int(end.timestamp() * 1000),
queries=[
BuilderQuery(
signal="metrics",
aggregations=[MetricAggregation(metric_name=metric_name, time_aggregation="latest", space_aggregation="sum", temporality="unspecified")],
step_interval=1,
).to_dict()
],
format_options={"formatTableResultForUI": False, "fillGaps": True},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["meta"]["stepIntervals"] == {"A": step_seconds}
assert get_all_warnings(response.json()) == [
{"message": f"Query A is requesting aggregation interval 1 seconds, which is smaller than the minimum allowed interval of {step_seconds} seconds for selected time range. Using the minimum instead"},
]
assert len(get_series_values(response.json(), "A")) == 301

View File

@@ -13,8 +13,11 @@ from fixtures.querier import (
build_aggregation,
build_group_by_field,
build_traces_scalar_query,
get_all_warnings,
get_preview_sql,
get_rows,
index_series_by_label,
make_preview_query_request,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces
@@ -467,3 +470,79 @@ def test_traces_attributes_json_list_view(
assert row["data"]["name"] == name
# int/float compare equal in Python, so map-sourced float64 and JSON-sourced numbers both match.
assert row["data"]["attributes"] == attributes, name
def test_traces_attributes_json_unreported_key(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
"""An attribute metadata does not report follows the rollout like a reported one: the legacy
map before it, the attributes JSON column after it."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
seed_attribute_evolution("traces", evolution_time)
before = evolution_time - timedelta(minutes=5)
after = evolution_time + timedelta(minutes=5)
spans = [
Traces(
timestamp=before,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="before",
attributes={"unreported.key": "value"},
attribute_write_mode="legacy_only",
),
Traces(
timestamp=after,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="after",
attributes={"unreported.key": "value"},
attribute_write_mode="json_only",
),
]
# no metadata rows, so the querier does not know the key
for span in spans:
span.attribute_keys = []
span.tag_attributes = []
insert_traces(spans)
queries = [
build_traces_scalar_query(
aggregations=[build_aggregation("count()")],
filter_expression="unreported.key = 'value'",
)
]
# before the rollout -> legacy map
start_ms = int((before - timedelta(minutes=1)).timestamp() * 1000)
end_ms = int((before + timedelta(minutes=1)).timestamp() * 1000)
sql = get_preview_sql(make_preview_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, queries=queries), "A")
assert "attributes_string['unreported.key']" in sql
assert "attributes.`unreported.key`" not in sql
response = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, queries=queries)
assert response.status_code == HTTPStatus.OK
series = response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"]
assert [value["value"] for value in series[0]["values"]] == [1]
assert any("not found in metadata" in warning.get("message", "") for warning in get_all_warnings(response.json()))
# after the rollout -> attributes JSON column
start_ms = int((after - timedelta(minutes=1)).timestamp() * 1000)
end_ms = int((after + timedelta(minutes=1)).timestamp() * 1000)
sql = get_preview_sql(make_preview_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, queries=queries), "A")
assert "attributes.`unreported.key`" in sql
assert "attributes_string" not in sql
response = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, queries=queries)
assert response.status_code == HTTPStatus.OK
series = response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"]
assert [value["value"] for value in series[0]["values"]] == [1]
assert any("not found in metadata" in warning.get("message", "") for warning in get_all_warnings(response.json()))

View File

@@ -16,6 +16,7 @@ from fixtures.types import Operation, SigNoz, TestContainerDocker
V2_RULES_URL = "/api/v2/rules"
V3_RULES_URL = "/api/v3/rules"
RULE_VIEWS_URL = "/api/v2/rule_views"
DOWNTIME_URL = "/api/v1/downtime_schedules"
ROUTE_POLICIES_URL = "/api/v1/route_policies"
@@ -28,6 +29,7 @@ _ACTOR_PASSWORD = "password123Z$"
_ACTOR_ROLE_NAME = "alert-authz-actor"
_TARGET_RULE = "alert authz target"
_VIEW_NAME = "alert-authz-view"
_DOWNTIME_NAME = "alert-authz-downtime"
_ACTOR_DOWNTIME_NAME = "alert-authz-actor-downtime"
_ROUTE_POLICY_NAME = "alert-authz-route-policy"
@@ -400,6 +402,45 @@ def test_viewer_allowed_on_downtime_reads_and_forbidden_on_writes(
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_viewer_allowed_on_rule_views(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{RULE_VIEWS_URL}/{view_id}"),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "asc"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{RULE_VIEWS_URL}/{view_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -506,6 +547,37 @@ def test_downtime_managed_role_tuples_enforced_with_license(
assert response.status_code == HTTPStatus.NO_CONTENT, f"editor delete must pass on the migrated planned-maintenance delete tuple: {response.text}"
def test_rule_view_managed_role_tuples_enforced_with_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
viewer_token = get_token(_VIEWER_EMAIL, _VIEWER_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"viewer list must pass on the migrated rule list tuple: {response.text}"
response = requests.post(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, f"viewer create must pass, rule views are gated on rule list like dashboard views: {response.text}"
view_id = response.json()["data"]["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{RULE_VIEWS_URL}/{view_id}"),
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"viewer delete must pass on the migrated rule list tuple: {response.text}"
def test_setup_channel_scoped_actor(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
@@ -630,6 +702,29 @@ def test_route_policy_channel_changes_follow_channel_attach_and_detach(
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
def test_rule_views_forbidden_without_rule_list(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(_ACTOR_EMAIL, _ACTOR_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(RULE_VIEWS_URL),
json={"name": _VIEW_NAME, "data": {"version": "v1", "sort": "updated_at", "order": "desc"}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
def test_downtime_naming_rule_forbidden_without_rule_attach(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument