Compare commits

...

22 Commits

Author SHA1 Message Date
srikanthccv
4cc7280f0d fix: evaluate PromQL on the step grid and expire cache buckets on their own clock
PromQL
- The querier moves the start and end of a PromQL query down to the
  step grid for every client, as a query frontend does before a results
  cache. Windows a fraction of a step apart now evaluate the same
  instants and share one cache entry. A request that sets
  noStepAlignment keeps its own instants and is not cached; alert rules
  set it, since they evaluate at their own time.
- A query with @ start() or @ end() is not cached: a piece of the
  window would evaluate something else than the whole.

Cache
- Each bucket records when it was written and expires on its own
  clock, since the entry's TTL restarts on every write. A coalesced
  bucket keeps the oldest write time.
- A read decodes only the buckets that overlap the window, and a write
  reuses the decoded data instead of decoding the entry again.
- The stats of a served bucket are attributed to the window in
  proportion to the part served, so a short window served from a long
  bucket no longer reports the whole bucket's scan.
- Request counters by result (hit, partial, miss) and dropped buckets
  by reason (expired, undecodable).

Table hints
- The helpers that pin the tables of a piece live in the telemetry
  schema packages; the querier no longer imports statement builders.

Tests
- The PromQL conformance corpus asks for its own instants. The cache
  suite runs in CI.

Assisted-by: Claude Fable 5.1
2026-09-28 22:45:46 +05:30
srikanthccv
f27b4798be fix: make the querier bucket cache agree with uncached queries
The bucket cache served answers that differed from the same request
with noCache. This change rebuilds the cache around one rule: a point
is served only for a window that fully contains its step, and a partial
step is served only for the window end that produced it.

Cache
- Body buckets hold whole steps and are coalesced on write, so a
  sliding window keeps one bucket instead of one copy per refresh.
- Edge buckets hold the partial first or last step of one window end,
  keyed by that exact end, and are capped at eight per entry.
- The step that straddles the flux boundary is not stored.
- Stats, warnings and the warnings URL are stored per bucket and summed
  or unioned for the served window only.
- A bucket that does not decode is dropped and its range is fetched
  again. Empty results are cached.
- Points are stored at full precision; the response encoder rounds
  them, and rounded values gave formulas other inputs than uncached.
- Writes of one key are serialised in-process. The key carries a schema
  version and a hash, and series keys quote label values.
- The memory provider deletes a key before it sets it, so an entry that
  grows is admitted against the cost budget again.

Querier
- A ranged piece takes the gap as it is; a timeShift window was shifted
  twice.
- A logs or traces top-N is keyed by its window and never assembled from
  pieces.
- Metrics pieces carry the table hints of the whole request, for
  metrics and meter sources.
- runningDiff keeps its lookback step on a cache hit.
- Served points are flagged partial against the query window, as
  consume flags them.
- A failed piece fails the request instead of running the whole window
  again. A panic in a query goroutine becomes an error.
- Anomaly sub-requests follow the request's NoCache flag. A
  Cache-Control: no-cache header bypasses the cache.

PromQL
- Only a window that starts on the step grid is cached; other windows
  run uncached instead of mixing phases.
- The window is half-open on the grid, so the instant at the end is
  inside it and served on a hit.
- Reserved variables render from the request window in every piece.

Rate and increase
- The previous sample of a cumulative series is used only within a
  lookback of max(step, 5m). The value of a bucket then depends only on
  the samples near it, not on where the statement window starts, so
  pieces agree with the whole window. A gap longer than the lookback
  yields no point for the bucket after it.

Tests
- Unit tests for every invariant above, a randomised in-process
  differential test (cached versus uncached over a fake ClickHouse),
  and integration suites under tests/integration/tests/queriercache.

Assisted-by: Claude Fable 5.1
2026-09-28 20:32:44 +05:30
Naman Verma
98d6ed18ed fix: remove test removed by main 2026-09-21 11:07:50 +05:30
Naman Verma
45aab4e6a2 Merge branch 'main' into nv/caching-edge-cases 2026-09-21 11:00:10 +05:30
Nityananda Gohain
ea8f95ee08 chore: enable ai 011y processors by default (#12912)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (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
<!--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
Enable the processors by default so that metadata is populated

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
No issue
2026-09-19 13:02:43 +00:00
Nityananda Gohain
b64116d67d feat: support for default attribute mapping (#12809)
<!--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

Until now every org started with no span mapper groups and we had to
create `llm`, `agent` and `tool` by hand. This PR ships them as
defaults.

- The three groups live as JSON in the binary. On startup and when a new
org is created, they get seeded. When we change a definition and bump
its version, the next release updates the org's copy in place.
- Anything SigNoz ships is marked `origin: system` and can only be
switched on or off. Users can't rename or delete these groups and
mappers, and can't take their names. Anything the user adds is theirs to
edit or remove, including new mappers in a shipped group or new sources
on a shipped mapper.
  - Upgrades keep the user's on/off choices and never touch their items.
- Condition substrings and sources now carry `enabled` and `origin`, so
a substring is an object instead of a plain string. Disabled ones are
left out of the collector config.
- - Migration 127 only adds the `origin` and `version` columns. Stored
JSON is not rewritten because these tables are empty on every instance.
- Fixes creating a group or mapper with `enabled: false` being saved as
true (the bun `default:true` tag turned false into SQL `DEFAULT`)
  
Frontend: no UI changes. Generated client regenerated; drafts carry
`enabled` and `origin` so saves from the existing screens round-trip
shipped items intact.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/5329


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
* Frontend follow-up: toggles for sources and substrings, "Default"
badge and read-only rows for shipped items, hide rename/delete on system
groups and mappers.
* Deferred: per-group upgrade changelog ( will come back to this later)

---------

Co-authored-by: Gaurav Tewari <gauravtewari111@gmail.com>
Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-19 11:22:46 +00:00
Naman Verma
2068482f66 feat: add api to repair malformed channels created via v1 (#12910)
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
<!--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

No need to write a db migration, notification channels can be repaired
if user asks to repair.

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

Part of https://github.com/SigNoz/pulse-pod/issues/342
2026-09-18 16:05:41 +00:00
Gaurav Tewari
35973efd65 feat(quick-filters): AI o11y quick filters (#12788)
#### Description

- The AI o11y explorer was reusing the traces explorer's quick filters.
It now uses its own `ai_observability` source and signal, so it loads
the gen_ai filter set.
- Values and settings keys go to
`/api/v1/ai_observability/fields/{values,keys}`, which scope suggestions
to gen_ai spans.


#### Issues closed by this PR
Close
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223108147&issue=SigNoz%7Cengineering-pod%7C5844

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/f7124648-9919-46e5-9e6d-90bd72f91a50


#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-18 09:49:13 +00:00
Naman Verma
c65845e525 feat: add more slack configuration opts in notification channels (#12907)
<!--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

Some common configuration options for a slack notification have been
added.

Also, webhook notification channels can now run without a username,
without a password, or without any auth.
2026-09-18 09:20:19 +00:00
Gaurav Tewari
f6f41df237 feat(ai-observability): Trace view changes (#12796)
#### Description

- Trace is now the explorer's default view (`DEFAULT_PANEL_TYPE`) and
the first toolbar tab.
- `LeftToolbarActions` becomes config-driven: buttons render in the
order the caller declares its views, from a `TOOLBAR_VIEW_CONFIG`
lookup, instead of five hardcoded per-view blocks. That also replaces
the `items: any` prop with a typed `Record<string, ToolbarViewItem>`.
- Fixes a column-init race in the trace view. Rows can land before the
field keys, and mounting then persisted a partial column set as if the
user had chosen it. `useTraceViewColumns` now hands out the column
storage key only once the keys fetch succeeds — every write path in
`useColumnState` no-ops without one, so the key is the write barrier.
Until then the table stays unmounted, the Options control is hidden, and
a render falls back to the default-visible columns with no key attached.
- Removes the saved-views / export-to-dashboard bar from the explorer
and the download menu from the list view — its export path only handles
`PANEL_TYPES.LIST`, so it could not reflect the selected columns.
`getQueryByPanelType` and `getExportQueryData` go with them. Table and
Time Series keep their exports under AI-specific filenames, via a new
opt-in `exportFileName` on the shared `TimeSeriesView` (defaulted, so
existing callers are unchanged).
- List view drops `useOptionsMenu`: columns are the static
`defaultSelectedColumns` (now typed `TelemetryFieldKey[]`) until the
preferences framework lands, and the table keeps its own column order
under `AI_OBSERVABILITY_LIST_COLUMNS` instead of sharing the traces
explorer's.
- `start_time`/`end_time`/`last_activity_time` join
`TIMESTAMP_FIELD_NAMES` and
`trace_duration_nano`/`max_llm_duration_nano` join
`DURATION_FIELD_NAMES`, so the shared `FieldCell` formats the
trace-level columns instead of a separate component.
- The explorer now imports its own forked `aiActions`, `Controls`,
`TracesTable` and list utils rather than reaching into `TracesExplorer`.
- Trims the forked `ListView/utils.tsx` to the two helpers the AI
explorer uses. `getListColumns`, `BlockLink` and `transformDataWithDate`
are antd-era machinery whose only consumer is `TracesTableComponent`,
which still imports them from the untouched original.
- Tests for the trace view, its column hook, and the table's column-init
race.
- Unrelated one-liner: `FieldKeysConfig`/`FieldValuesConfig` now point
at the generic endpoint's param types instead of being a union with the
AI ones. `Omit` over a union keeps only the keys both members share, so
`FieldKeysConfigProp` was silently losing `source`, `metricName` and
`metricNamespace`, and metrics/meter could not have used the picker. The
AI params are a subset of the generic ones and the endpoint ignores
extras.

#### Issues closed by this PR

close
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=223108466&issue=SigNoz%7Cengineering-pod%7C5845

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/b593e160-f81e-49d4-a092-20d86bc46515

#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-18 08:36:26 +00:00
Naman Verma
0755563d4e Merge branch 'main' into nv/caching-edge-cases 2026-09-17 11:51:18 +05:30
Naman Verma
aa9893e818 fix: add cache fixes for heatmap 2026-09-16 16:22:00 +05:30
Naman Verma
a9d6a35ccc Merge branch 'main' into nv/caching-edge-cases 2026-09-16 16:20:59 +05:30
Naman Verma
0d279c1b96 Merge branch 'main' into nv/caching-edge-cases 2026-09-16 09:45:44 +05:30
Naman Verma
082cd85e6a chore: move integration test file number 2026-09-14 22:09:13 +05:30
Naman Verma
32603ff9aa Merge branch 'main' into nv/caching-edge-cases 2026-09-14 22:02:02 +05:30
Naman Verma
baf0afd178 Merge branch 'main' into nv/caching-edge-cases 2026-09-11 02:51:49 +05:30
Naman Verma
a9615badc0 fix: add cache fixes 2026-09-09 18:31:51 +05:30
Naman Verma
7174733b84 test: test for values in each cached call test 2026-09-09 16:29:03 +05:30
Naman Verma
17b8f6a288 test: test for values in each cached call in sliding time range 2026-09-09 15:59:41 +05:30
Naman Verma
12783a35ad test: more descriptive var names in test 2026-09-09 15:52:13 +05:30
Naman Verma
c205ea99b5 test: add caching edge case integration tests 2026-09-09 15:43:39 +05:30
135 changed files with 9938 additions and 3770 deletions

View File

@@ -56,6 +56,7 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- queriercache
- querierai
- rawexportdata
- promqlconformance

View File

@@ -171,6 +171,14 @@ components:
- kind
- spec
type: object
AlertmanagertypesChannelDefect:
enum:
- none
- missing_type
- multiple_notifiers
- unsupported_notifier
- unrepresentable
type: string
AlertmanagertypesChannelEmailConfig:
properties:
headers:
@@ -384,13 +392,83 @@ components:
required:
- routingKey
type: object
AlertmanagertypesChannelRepair:
properties:
action:
$ref: '#/components/schemas/AlertmanagertypesChannelRepairAction'
applied:
type: boolean
blockers:
items:
type: string
type: array
channels:
items:
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
nullable: true
type: array
defect:
$ref: '#/components/schemas/AlertmanagertypesChannelDefect'
detail:
type: string
id:
type: string
required:
- id
- defect
- action
- applied
type: object
AlertmanagertypesChannelRepairAction:
enum:
- none
- retype
- split
- delete
type: string
AlertmanagertypesChannelSlackAction:
properties:
confirm:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfirmation'
name:
type: string
style:
type: string
text:
type: string
type:
type: string
url:
type: string
value:
type: string
required:
- type
- text
type: object
AlertmanagertypesChannelSlackConfig:
properties:
actions:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackAction'
type: array
apiUrl:
format: password
type: string
channel:
type: string
color:
type: string
fallback:
type: string
fields:
items:
$ref: '#/components/schemas/AlertmanagertypesChannelSlackField'
type: array
footer:
type: string
pretext:
type: string
sendResolved:
nullable: true
type: boolean
@@ -398,9 +476,37 @@ components:
type: string
title:
type: string
titleLink:
type: string
required:
- apiUrl
type: object
AlertmanagertypesChannelSlackConfirmation:
properties:
dismissText:
type: string
okText:
type: string
text:
type: string
title:
type: string
required:
- text
type: object
AlertmanagertypesChannelSlackField:
properties:
short:
nullable: true
type: boolean
title:
type: string
value:
type: string
required:
- title
- value
type: object
AlertmanagertypesChannelWebhookConfig:
properties:
bearerToken:
@@ -969,6 +1075,11 @@ components:
- duration
- repeatType
type: object
AlertmanagertypesRepairChannelParams:
properties:
apply:
type: boolean
type: object
AlertmanagertypesRepeatOn:
enum:
- sunday
@@ -8263,6 +8374,8 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5FormatOptions'
noCache:
type: boolean
noStepAlignment:
type: boolean
requestType:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
schemaVersion:
@@ -9621,6 +9734,8 @@ components:
type: string
name:
type: string
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
updatedAt:
format: date-time
type: string
@@ -9633,6 +9748,7 @@ components:
- fieldContext
- config
- enabled
- origin
type: object
SpantypesSpanMapperConfig:
properties:
@@ -9661,48 +9777,75 @@ components:
type: string
orgId:
type: string
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
updatedAt:
format: date-time
type: string
updatedBy:
type: string
version:
type: integer
required:
- id
- orgId
- name
- condition
- enabled
- origin
- version
type: object
SpantypesSpanMapperGroupCondition:
nullable: true
properties:
attributes:
items:
type: string
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
nullable: true
type: array
resource:
items:
type: string
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
nullable: true
type: array
required:
- attributes
- resource
type: object
SpantypesSpanMapperGroupConditionKey:
properties:
enabled:
type: boolean
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
value:
type: string
required:
- value
- enabled
type: object
SpantypesSpanMapperOperation:
enum:
- move
- copy
type: string
SpantypesSpanMapperOrigin:
enum:
- user
- system
type: string
SpantypesSpanMapperSource:
properties:
context:
$ref: '#/components/schemas/SpantypesFieldContext'
enabled:
type: boolean
key:
type: string
operation:
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
origin:
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
priority:
type: integer
required:
@@ -9710,6 +9853,7 @@ components:
- context
- operation
- priority
- enabled
type: object
SpantypesSpanMapperTestSpan:
properties:
@@ -20242,6 +20386,85 @@ paths:
summary: Update notification channel
tags:
- channels
/api/v2/notification_channels/{id}/repair:
post:
deprecated: false
description: 'This endpoint diagnoses a stored channel that the v2 API cannot
read and applies the fitting action: a channel carrying several notifier configurations
is split into one channel per configuration, keeping this ID for the first;
a channel whose notifier kind v2 does not model is deleted; a channel with
an empty stored type has it rewritten from its data. A delete is refused while
a routing policy still names the channel. Nothing is written unless apply=true;
by default the response only shows what would happen.'
operationId: RepairNotificationChannel
parameters:
- in: query
name: apply
schema:
type: boolean
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesRepairChannelParams'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesChannelRepair'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:update
- tokenizer:
- notification-channel:update
summary: Repair notification channel
tags:
- channels
/api/v2/notification_channels/test:
post:
deprecated: false

View File

@@ -90,7 +90,7 @@ func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seaso
End: end,
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: req.CompositeQuery,
NoCache: false,
NoCache: req.NoCache,
}
var pastPeriodStart, pastPeriodEnd uint64
@@ -115,7 +115,7 @@ func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seaso
End: pastPeriodEnd,
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: req.CompositeQuery,
NoCache: false,
NoCache: req.NoCache,
}
// seasonality growth trend
@@ -137,7 +137,7 @@ func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seaso
End: currentGrowthPeriodEnd,
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: req.CompositeQuery,
NoCache: false,
NoCache: req.NoCache,
}
var pastGrowthPeriodStart, pastGrowthPeriodEnd uint64
@@ -158,7 +158,7 @@ func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seaso
End: pastGrowthPeriodEnd,
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: req.CompositeQuery,
NoCache: false,
NoCache: req.NoCache,
}
var past2GrowthPeriodStart, past2GrowthPeriodEnd uint64
@@ -179,7 +179,7 @@ func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seaso
End: past2GrowthPeriodEnd,
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: req.CompositeQuery,
NoCache: false,
NoCache: req.NoCache,
}
var past3GrowthPeriodStart, past3GrowthPeriodEnd uint64
@@ -200,7 +200,7 @@ func prepareAnomalyQueryParams(req *qbtypes.QueryRangeRequest, seasonality Seaso
End: past3GrowthPeriodEnd,
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: req.CompositeQuery,
NoCache: false,
NoCache: req.NoCache,
}
return &anomalyQueryParams{

View File

@@ -121,7 +121,8 @@ func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbty
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0),
},
NoCache: true,
NoCache: true,
NoStepAlignment: true,
}
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)

View File

@@ -21,6 +21,7 @@ import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
AlertmanagertypesRepairChannelParamsDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
CreateChannel201,
@@ -35,6 +36,9 @@ import type {
ListNotificationChannels200,
ListNotificationChannelsParams,
RenderErrorResponseDTO,
RepairNotificationChannel200,
RepairNotificationChannelParams,
RepairNotificationChannelPathParameters,
UpdateChannelByIDPathParameters,
UpdateNotificationChannel200,
UpdateNotificationChannelPathParameters,
@@ -1144,6 +1148,113 @@ export const useUpdateNotificationChannel = <
> => {
return useMutation(getUpdateNotificationChannelMutationOptions(options));
};
/**
* This endpoint diagnoses a stored channel that the v2 API cannot read and applies the fitting action: a channel carrying several notifier configurations is split into one channel per configuration, keeping this ID for the first; a channel whose notifier kind v2 does not model is deleted; a channel with an empty stored type has it rewritten from its data. A delete is refused while a routing policy still names the channel. Nothing is written unless apply=true; by default the response only shows what would happen.
* @summary Repair notification channel
*/
export const repairNotificationChannel = (
{ id }: RepairNotificationChannelPathParameters,
alertmanagertypesRepairChannelParamsDTO?: BodyType<AlertmanagertypesRepairChannelParamsDTO>,
params?: RepairNotificationChannelParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<RepairNotificationChannel200>({
url: `/api/v2/notification_channels/${id}/repair`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesRepairChannelParamsDTO,
params,
signal,
});
};
export const getRepairNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof repairNotificationChannel>>,
TError,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof repairNotificationChannel>>,
TError,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
},
TContext
> => {
const mutationKey = ['repairNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof repairNotificationChannel>>,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
}
> = (props) => {
const { pathParams, data, params } = props ?? {};
return repairNotificationChannel(pathParams, data, params);
};
return { mutationFn, ...mutationOptions };
};
export type RepairNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof repairNotificationChannel>>
>;
export type RepairNotificationChannelMutationBody =
| BodyType<AlertmanagertypesRepairChannelParamsDTO>
| undefined;
export type RepairNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Repair notification channel
*/
export const useRepairNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof repairNotificationChannel>>,
TError,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof repairNotificationChannel>>,
TError,
{
pathParams: RepairNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesRepairChannelParamsDTO>;
params?: RepairNotificationChannelParams;
},
TContext
> => {
return useMutation(getRepairNotificationChannelMutationOptions(options));
};
/**
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
* @summary Test notification channel

View File

@@ -40,7 +40,73 @@ export interface AlertmanagertypesChannelDTO {
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
slack = 'slack',
}
export interface AlertmanagertypesChannelSlackConfirmationDTO {
/**
* @type string
*/
dismissText?: string;
/**
* @type string
*/
okText?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
title?: string;
}
export interface AlertmanagertypesChannelSlackActionDTO {
confirm?: AlertmanagertypesChannelSlackConfirmationDTO;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
style?: string;
/**
* @type string
*/
text: string;
/**
* @type string
*/
type: string;
/**
* @type string
*/
url?: string;
/**
* @type string
*/
value?: string;
}
export interface AlertmanagertypesChannelSlackFieldDTO {
/**
* @type boolean,null
*/
short?: boolean | null;
/**
* @type string
*/
title: string;
/**
* @type string
*/
value: string;
}
export interface AlertmanagertypesChannelSlackConfigDTO {
/**
* @type array
*/
actions?: AlertmanagertypesChannelSlackActionDTO[];
/**
* @type string
* @format password
@@ -50,6 +116,26 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
channel?: string;
/**
* @type string
*/
color?: string;
/**
* @type string
*/
fallback?: string;
/**
* @type array
*/
fields?: AlertmanagertypesChannelSlackFieldDTO[];
/**
* @type string
*/
footer?: string;
/**
* @type string
*/
pretext?: string;
/**
* @type boolean,null
*/
@@ -62,6 +148,10 @@ export interface AlertmanagertypesChannelSlackConfigDTO {
* @type string
*/
title?: string;
/**
* @type string
*/
titleLink?: string;
}
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
@@ -506,6 +596,13 @@ export type AlertmanagertypesChannelConfigDTO =
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
export enum AlertmanagertypesChannelDefectDTO {
none = 'none',
missing_type = 'missing_type',
multiple_notifiers = 'multiple_notifiers',
unsupported_notifier = 'unsupported_notifier',
unrepresentable = 'unrepresentable',
}
export enum AlertmanagertypesChannelKindDTO {
slack = 'slack',
email = 'email',
@@ -527,6 +624,63 @@ export enum AlertmanagertypesChannelListSortDTO {
created_at = 'created_at',
name = 'name',
}
export enum AlertmanagertypesChannelRepairActionDTO {
none = 'none',
retype = 'retype',
split = 'split',
delete = 'delete',
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesChannelRepairDTO {
action: AlertmanagertypesChannelRepairActionDTO;
/**
* @type boolean
*/
applied: boolean;
/**
* @type array
*/
blockers?: string[];
/**
* @type array,null
*/
channels?: AlertmanagertypesListedNotificationChannelDTO[] | null;
defect: AlertmanagertypesChannelDefectDTO;
/**
* @type string
*/
detail?: string;
/**
* @type string
*/
id: string;
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -1020,32 +1174,6 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
wont_fix_resolution?: string;
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesListableNotificationChannelDTO {
/**
* @type array
@@ -2449,6 +2577,13 @@ export interface AlertmanagertypesReceiverDTO {
wechat_configs?: ConfigWechatConfigDTO[];
}
export interface AlertmanagertypesRepairChannelParamsDTO {
/**
* @type boolean
*/
apply?: boolean;
}
export interface AlertmanagertypesTestableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
@@ -9659,6 +9794,10 @@ export interface Querybuildertypesv5QueryRangeRequestDTO {
* @type boolean
*/
noCache?: boolean;
/**
* @type boolean
*/
noStepAlignment?: boolean;
requestType?: Querybuildertypesv5RequestTypeDTO;
/**
* @type string
@@ -10682,6 +10821,22 @@ export interface SpantypesGettableFlamegraphTraceDTO {
startTimestampMillis: number;
}
export enum SpantypesSpanMapperOriginDTO {
user = 'user',
system = 'system',
}
export interface SpantypesSpanMapperGroupConditionKeyDTO {
/**
* @type boolean
*/
enabled: boolean;
origin?: SpantypesSpanMapperOriginDTO;
/**
* @type string
*/
value: string;
}
/**
* @nullable
*/
@@ -10689,11 +10844,11 @@ export type SpantypesSpanMapperGroupConditionDTO = {
/**
* @type array,null
*/
attributes: string[] | null;
attributes: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
/**
* @type array,null
*/
resource: string[] | null;
resource: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
} | null;
export interface SpantypesSpanMapperGroupDTO {
@@ -10723,6 +10878,7 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
*/
orgId: string;
origin: SpantypesSpanMapperOriginDTO;
/**
* @type string
* @format date-time
@@ -10732,6 +10888,10 @@ export interface SpantypesSpanMapperGroupDTO {
* @type string
*/
updatedBy?: string;
/**
* @type integer
*/
version: number;
}
export interface SpantypesGettableSpanMapperGroupsDTO {
@@ -10789,11 +10949,16 @@ export enum SpantypesSpanMapperOperationDTO {
}
export interface SpantypesSpanMapperSourceDTO {
context: SpantypesFieldContextDTO;
/**
* @type boolean
*/
enabled: boolean;
/**
* @type string
*/
key: string;
operation: SpantypesSpanMapperOperationDTO;
origin?: SpantypesSpanMapperOriginDTO;
/**
* @type integer
*/
@@ -10835,6 +11000,7 @@ export interface SpantypesSpanMapperDTO {
* @type string
*/
name: string;
origin: SpantypesSpanMapperOriginDTO;
/**
* @type string
* @format date-time
@@ -13394,6 +13560,25 @@ export type UpdateNotificationChannel200 = {
status: string;
};
export type RepairNotificationChannelPathParameters = {
id: string;
};
export type RepairNotificationChannelParams = {
/**
* @type boolean
* @description undefined
*/
apply?: boolean;
};
export type RepairNotificationChannel200 = {
data: AlertmanagertypesChannelRepairDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -1,21 +1,15 @@
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValuesParams,
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
} from 'api/generated/services/sigNoz.schemas';
export type FieldKeysConfig =
| GetFieldsKeysParams
| GetAIObservabilityFieldsKeysParams;
export type FieldKeysConfig = GetFieldsKeysParams;
export type FieldValuesConfig =
| GetFieldsValuesParams
| GetAIObservabilityFieldsValuesParams;
export type FieldValuesConfig = GetFieldsValuesParams;
export type FieldKeysConfigProp = Omit<
FieldKeysConfig,

View File

@@ -15,7 +15,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import CheckboxFilterHeader from './CheckboxFilterHeader';
import CheckboxValueRow from './CheckboxValueRow';
import LogsQuickFilterEmptyState from './LogsQuickFilterEmptyState';
import useActiveQueryIndex from './useActiveQueryIndex';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useCheckboxDisclosure from './useCheckboxDisclosure';
import useCheckboxFilterActions from './useCheckboxFilterActions';
import useCheckboxFilterState from './useCheckboxFilterState';

View File

@@ -56,6 +56,57 @@ export function mockFieldsValuesAPI(response: {
);
}
/**
* Records every request the AI observability values endpoint receives, so a test
* can assert both the routing and the query params it was called with.
*/
export function mockAIObservabilityFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];
numberValues?: (number | null)[];
}): { requests: URLSearchParams[] } {
const requests: URLSearchParams[] = [];
server.use(
rest.get(
'http://localhost/api/v1/ai_observability/fields/values',
(req, res, ctx) => {
requests.push(req.url.searchParams);
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: response.relatedValues ?? [],
stringValues: response.stringValues ?? [],
numberValues: response.numberValues ?? [],
},
},
}),
);
},
),
);
return { requests };
}
/** Fails the test if the signal-wide values endpoint is hit at all. */
export function forbidFieldsValuesAPI(): { called: boolean } {
const state = { called: false };
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) => {
state.called = true;
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
}),
);
return state;
}
export function mockFieldsValuesAPILoading(): void {
server.use(
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>

View File

@@ -16,7 +16,7 @@ import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from '../useActiveQueryIndex';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';

View File

@@ -0,0 +1,81 @@
import { screen, waitFor } from '@testing-library/react';
import { render } from 'tests/test-utils';
import { QuickFiltersSource } from '../../../../types';
import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
forbidFieldsValuesAPI,
mockAIObservabilityFieldsValuesAPI,
mockFieldsValuesAPI,
setupServer,
} from '../CheckboxFilterV2.testUtils';
setupServer();
describe('CheckboxFilterV2 - AI observability routing', () => {
it('reads values from the AI observability endpoint and never the signal-wide one', async () => {
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['openai', 'anthropic'],
});
const fieldsEndpoint = forbidFieldsValuesAPI();
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('openai')).resolves.toBeInTheDocument();
expect(screen.getByText('anthropic')).toBeInTheDocument();
expect(fieldsEndpoint.called).toBe(false);
expect(aiEndpoint.requests).toHaveLength(1);
});
it('forwards the filter key and the time range to the AI observability endpoint', async () => {
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['openai'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.AI_OBSERVABILITY}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByText('openai');
const params = aiEndpoint.requests[0];
expect(params.get('name')).toBe(DEFAULT_FILTER.attributeKey.key);
expect(params.get('startUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.startUnixMilli),
);
expect(params.get('endUnixMilli')).toBe(
String(DEFAULT_USE_FIELD_APIS.endUnixMilli),
);
});
it('keeps non-AI sources on the signal-wide endpoint', async () => {
mockFieldsValuesAPI({ stringValues: ['production'] });
const aiEndpoint = mockAIObservabilityFieldsValuesAPI({
stringValues: ['should-not-be-used'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(screen.findByText('production')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiEndpoint.requests).toHaveLength(0));
});
});

View File

@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldValuesConfig } from 'api/querySuggestions/types';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { useFieldValuesSuggestion } from 'hooks/querySuggestions/useFieldValuesSuggestion';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { DATA_SOURCE_TO_SIGNAL } from 'types/common/queryBuilder';
interface UseFieldValuesProps {
@@ -42,32 +43,43 @@ export function useFieldValues({
endUnixMilli,
enabled,
}: UseFieldValuesProps): UseFieldValuesReturn {
const { data, isLoading, isFetching } = useGetFieldsValues(
{
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
},
{
query: {
enabled,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
},
},
);
const isAIObservability = source === QuickFiltersSource.AI_OBSERVABILITY;
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
: undefined;
// The AI values endpoint is already gen_ai-scoped: no signal, no source.
const fieldValuesConfig: FieldValuesConfig = isAIObservability
? {
name: filter.attributeKey.key,
searchText,
existingQuery,
startUnixMilli,
endUnixMilli,
}
: {
signal: filter.dataSource
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
: undefined,
name: filter.attributeKey.key,
searchText,
existingQuery,
metricNamespace,
source: source ? QUICK_FILTERS_SOURCE_TO_SOURCE[source] : undefined,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
endUnixMilli,
};
const {
data: values,
isLoading,
isFetching,
} = useFieldValuesSuggestion(fieldValuesConfig, builderQueryType, { enabled });
const relatedValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
@@ -78,10 +90,9 @@ export function useFieldValues({
value !== null && value !== undefined && value !== '',
) || []
);
}, [data]);
}, [values]);
const allValues: string[] = useMemo(() => {
const values = data?.data?.values;
if (!values) {
return [];
}
@@ -101,7 +112,7 @@ export function useFieldValues({
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues, ...boolValues];
}, [data]);
}, [values]);
return { relatedValues, allValues, isLoading, isFetching };
}

View File

@@ -1,11 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Collapse } from 'antd';
import { Undo2 } from '@signozhq/icons';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import {
IQuickFiltersConfig,
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -39,7 +39,7 @@ function Duration({
}: {
filter: IQuickFiltersConfig;
onFilterChange?: (query: Query) => void;
source?: QuickFiltersSource;
source: QuickFiltersSource;
}): JSX.Element {
const [selectedFilters, setSelectedFilters] =
useState<
@@ -52,26 +52,11 @@ function Duration({
filter.defaultOpen ? 'durationNano' : '',
]);
const {
currentQuery,
redirectWithQueryBuilderData,
lastUsedQuery,
panelType,
} = useQueryBuilder();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
const isListView = panelType === PANEL_TYPES.LIST;
// In ListView mode, use index 0 for most sources; for TRACES_EXPLORER, use lastUsedQuery
// Otherwise use lastUsedQuery for non-ListView modes
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
const activeQueryIndex = useActiveQueryIndex(source);
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {

View File

@@ -35,6 +35,7 @@ import { isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
import Duration from './FilterRenderers/Duration/Duration';
import Slider from './FilterRenderers/Slider/Slider';
@@ -113,14 +114,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const shouldShowDropdownInListView =
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
const activeQueryIndex = useMemo(() => {
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
// AI observability builds a single query in the row-level views, so there is
// no query for the selector to switch between.
const isAIObservabilityRowView =
source === QuickFiltersSource.AI_OBSERVABILITY &&
(isListView || panelType === PANEL_TYPES.TRACE);
const activeQueryIndex = useActiveQueryIndex(source);
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
@@ -167,9 +167,10 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
// In ListView, always show the 0th query's name; otherwise use the active query's name
const displayedQueryName = isListView
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const displayedQueryName =
isListView || isAIObservabilityRowView
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const handleQueryChange = (value: number): void => {
setLastUsedQuery(value);
@@ -182,7 +183,9 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
<Typography.Text className="text">
{displayedQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
{queryOptions.length > 1 && (!isListView || shouldShowDropdownInListView) ? (
{queryOptions.length > 1 &&
!isAIObservabilityRowView &&
(!isListView || shouldShowDropdownInListView) ? (
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxTrigger
placeholder="Select a query"
@@ -318,6 +321,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
return (
<Duration
key={filter.attributeKey.key}
source={source}
filter={filter}
onFilterChange={onFilterChange}
/>

View File

@@ -1,12 +1,14 @@
import { useMemo } from 'react';
import { Button, Skeleton } from 'antd';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { FieldKeysConfig } from 'api/querySuggestions/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useFieldKeysSuggestion } from 'hooks/querySuggestions/useFieldKeysSuggestion';
import {
BuilderQueryType,
FieldContext,
FieldDataType,
TelemetryFieldKey,
@@ -41,23 +43,31 @@ function OtherFilters({
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const isAIObservability = signal === SignalType.AI_OBSERVABILITY;
const { data, isFetching } = useGetFieldsKeys(
{
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
},
{ query: { enabled: !!signal } },
const builderQueryType: BuilderQueryType | undefined = isAIObservability
? 'builder_ai_query'
: undefined;
const fieldKeysConfig: FieldKeysConfig = isAIObservability
? { searchText: inputValue }
: {
searchText: inputValue,
signal: signal
? DATA_SOURCE_TO_SIGNAL[SIGNAL_DATA_SOURCE_MAP[signal]]
: undefined,
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
};
const { data: fetchedKeys, isFetching } = useFieldKeysSuggestion(
fieldKeysConfig,
builderQueryType,
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
const suggestions: TelemetryFieldKey[] = (fetchedKeys ?? []).map((attr) => ({
name: attr.name,
signal: attr.signal as TelemetryFieldKey['signal'],
fieldContext: attr.fieldContext as FieldContext,
@@ -71,7 +81,7 @@ function OtherFilters({
),
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
}, [fetchedKeys, addedFilters]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);

View File

@@ -0,0 +1,81 @@
import { screen, waitFor } from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render } from 'tests/test-utils';
import { SignalType } from '../../types';
import OtherFilters from '../OtherFilters';
const BASE_URL = ENVIRONMENT.baseURL;
const FIELDS_KEYS_URL = `${BASE_URL}/api/v1/fields/keys`;
const AI_KEYS_URL = `${BASE_URL}/api/v1/ai_observability/fields/keys`;
function keysResponse(name: string): Record<string, unknown> {
return {
status: 'success',
data: {
complete: true,
keys: {
[name]: [{ name, fieldContext: 'attribute', fieldDataType: 'string' }],
},
},
};
}
describe('OtherFilters - AI observability keys', () => {
let fieldsKeysCalled: boolean;
let aiKeysParams: URLSearchParams | undefined;
beforeEach(() => {
fieldsKeysCalled = false;
aiKeysParams = undefined;
server.use(
rest.get(FIELDS_KEYS_URL, (_, res, ctx) => {
fieldsKeysCalled = true;
return res(ctx.status(200), ctx.json(keysResponse('http.route')));
}),
rest.get(AI_KEYS_URL, (req, res, ctx) => {
aiKeysParams = req.url.searchParams;
return res(ctx.status(200), ctx.json(keysResponse('gen_ai.request.model')));
}),
);
});
function renderOtherFilters(signal: SignalType): void {
render(
<OtherFilters
signal={signal}
inputValue=""
addedFilters={[]}
setAddedFilters={jest.fn()}
/>,
);
}
it('reads AI observability keys from their own endpoint', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
await expect(
screen.findByText('gen_ai.request.model'),
).resolves.toBeInTheDocument();
expect(fieldsKeysCalled).toBe(false);
});
it('does not narrow the AI keys by fieldContext', async () => {
renderOtherFilters(SignalType.AI_OBSERVABILITY);
// A `trace` context would return only the computed per-trace aggregates,
// which cannot be filtered on.
await waitFor(() => expect(aiKeysParams).toBeDefined());
expect(aiKeysParams?.get('fieldContext')).toBeNull();
});
it('keeps other signals on the signal-wide keys endpoint', async () => {
renderOtherFilters(SignalType.TRACES);
await expect(screen.findByText('http.route')).resolves.toBeInTheDocument();
await waitFor(() => expect(aiKeysParams).toBeUndefined());
});
});

View File

@@ -7,4 +7,5 @@ export const SIGNAL_DATA_SOURCE_MAP = {
[SignalType.EXCEPTIONS]: DataSource.TRACES,
[SignalType.API_MONITORING]: DataSource.TRACES,
[SignalType.METER_EXPLORER]: DataSource.METRICS,
[SignalType.AI_OBSERVABILITY]: DataSource.TRACES,
};

View File

@@ -0,0 +1,81 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { QuickFiltersSource } from '../../types';
import useActiveQueryIndex from '../useActiveQueryIndex';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
const LAST_USED_QUERY = 2;
function mockQueryBuilder(panelType: PANEL_TYPES): void {
(useQueryBuilder as jest.Mock).mockReturnValue({
lastUsedQuery: LAST_USED_QUERY,
panelType,
});
}
describe('useActiveQueryIndex', () => {
describe('AI observability builds a single query in the row-level views', () => {
it.each([PANEL_TYPES.LIST, PANEL_TYPES.TRACE])(
'drives the first query in %s',
(panelType) => {
mockQueryBuilder(panelType);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
);
expect(result.current).toBe(0);
},
);
it.each([PANEL_TYPES.TIME_SERIES, PANEL_TYPES.TABLE])(
'follows the last used query in %s',
(panelType) => {
mockQueryBuilder(panelType);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.AI_OBSERVABILITY),
);
expect(result.current).toBe(LAST_USED_QUERY);
},
);
});
describe('other sources are unchanged', () => {
it('lets the traces explorer track the last used query in list view', () => {
mockQueryBuilder(PANEL_TYPES.LIST);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.TRACES_EXPLORER),
);
expect(result.current).toBe(LAST_USED_QUERY);
});
it('pins single-query sources to the first query in list view', () => {
mockQueryBuilder(PANEL_TYPES.LIST);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.INFRA_MONITORING),
);
expect(result.current).toBe(0);
});
it('tracks the last used query outside list view', () => {
mockQueryBuilder(PANEL_TYPES.TIME_SERIES);
const { result } = renderHook(() =>
useActiveQueryIndex(QuickFiltersSource.LOGS_EXPLORER),
);
expect(result.current).toBe(LAST_USED_QUERY);
});
});
});

View File

@@ -15,13 +15,21 @@ function useActiveQueryIndex(source: QuickFiltersSource): number {
const isListView = panelType === PANEL_TYPES.LIST;
return useMemo(() => {
// AI observability builds a single query in the row-level views, so its
// filters always drive the first one there.
if (source === QuickFiltersSource.AI_OBSERVABILITY) {
return isListView || panelType === PANEL_TYPES.TRACE
? 0
: lastUsedQuery || 0;
}
if (isListView) {
return source === QuickFiltersSource.TRACES_EXPLORER
? lastUsedQuery || 0
: 0;
}
return lastUsedQuery || 0;
}, [isListView, source, lastUsedQuery]);
}, [isListView, panelType, source, lastUsedQuery]);
}
export default useActiveQueryIndex;

View File

@@ -24,6 +24,7 @@ export enum SignalType {
API_MONITORING = 'api_monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai_observability',
}
/**
@@ -69,6 +70,7 @@ export enum QuickFiltersSource {
API_MONITORING = 'api-monitoring',
EXCEPTIONS = 'exceptions',
METER_EXPLORER = 'meter',
AI_OBSERVABILITY = 'ai-observability',
}
/**

View File

@@ -109,6 +109,9 @@ export const REACT_QUERY_KEY = {
// Field Keys Suggestion Query Keys
FIELD_KEYS_SUGGESTION: 'FIELD_KEYS_SUGGESTION',
// Field Values Suggestion Query Keys
FIELD_VALUES_SUGGESTION: 'FIELD_VALUES_SUGGESTION',
// AI Assistant Query Keys
AI_ASSISTANT_EMPTY_STATE_CHIPS: 'AI_ASSISTANT_EMPTY_STATE_CHIPS',
} as const;

View File

@@ -333,6 +333,7 @@ describe('AttributeMappingsTab (integration)', () => {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
enabled: true,
})),
},
}),

View File

@@ -1,10 +1,12 @@
import { Typography } from '@signozhq/ui/typography';
import { ConditionKey } from 'container/LLMObservability/AttributeMapping/types';
import styles from './ConditionsTooltip.module.scss';
interface ConditionsTooltipProps {
attributes: string[];
resource: string[];
attributes: ConditionKey[];
resource: ConditionKey[];
}
function ConditionsTooltip({
@@ -33,8 +35,8 @@ function ConditionsTooltip({
</Typography.Text>
<div className={styles.keyList}>
{attributes.map((key) => (
<code key={key} className={styles.key}>
{key}
<code key={`${key.origin}-${key.value}`} className={styles.key}>
{key.value}
</code>
))}
</div>
@@ -47,8 +49,8 @@ function ConditionsTooltip({
</Typography.Text>
<div className={styles.keyList}>
{resource.map((key) => (
<code key={key} className={styles.key}>
{key}
<code key={`${key.origin}-${key.value}`} className={styles.key}>
{key.value}
</code>
))}
</div>

View File

@@ -3,6 +3,7 @@ import {
SpantypesSpanMapperDTO as Mapper,
SpantypesSpanMapperGroupDTO as MapperGroup,
SpantypesSpanMapperOperationDTO as MapperOperation,
SpantypesSpanMapperOriginDTO as MapperOrigin,
SpantypesSpanMapperTestSpanDTO as TestSpan,
} from 'api/generated/services/sigNoz.schemas';
@@ -21,9 +22,15 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
orgId: 'org-1',
name: 'demo',
enabled: true,
origin: MapperOrigin.user,
version: 0,
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
attributes: [
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
],
resource: [
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
],
},
...overrides,
};
@@ -35,6 +42,7 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
groupId: 'group-1',
name: 'gen_ai.request.model',
enabled: true,
origin: MapperOrigin.user,
fieldContext: FieldContext.attribute,
config: {
sources: [
@@ -43,12 +51,16 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority: 2,
enabled: true,
origin: MapperOrigin.user,
},
{
key: 'llm.model',
context: FieldContext.attribute,
operation: MapperOperation.move,
priority: 1,
enabled: true,
origin: MapperOrigin.user,
},
],
},
@@ -85,8 +97,12 @@ export const mockGroups: MapperGroup[] = [
id: 'group-1',
name: 'demo',
condition: {
attributes: ['ai.embeddings'],
resource: ['cloud.account.id'],
attributes: [
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
],
resource: [
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
],
},
}),
makeGroup({

View File

@@ -1,19 +1,23 @@
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
import {
ConditionKey,
FieldContextValue,
} from 'container/LLMObservability/AttributeMapping/types';
import { createConditionKey } from 'container/LLMObservability/AttributeMapping/utils';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './ConditionKeyList.module.scss';
interface ConditionKeyListProps {
label: string;
labelHint?: string;
keys: string[];
keys: ConditionKey[];
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
onChange: (keys: ConditionKey[]) => void;
}
function ConditionKeyList({
@@ -27,11 +31,11 @@ function ConditionKeyList({
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
onChange(keys.map((key, i) => (i === index ? { ...key, value } : key)));
};
const addKey = (): void => {
onChange([...keys, '']);
onChange([...keys, createConditionKey()]);
};
const removeKey = (index: number): void => {
@@ -53,7 +57,7 @@ function ConditionKeyList({
<KeySearchInput
className={styles.keyInput}
placeholder={placeholder}
value={key}
value={key.value}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}

View File

@@ -42,7 +42,9 @@ function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
(source, index) =>
source.key === b[index].key &&
source.context === b[index].context &&
source.operation === b[index].operation,
source.operation === b[index].operation &&
source.enabled === b[index].enabled &&
source.origin === b[index].origin,
)
);
}

View File

@@ -1,8 +1,11 @@
import {
SpantypesFieldContextDTO,
SpantypesSpanMapperDTO,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesSpanMapperGroupDTO,
SpantypesSpanMapperOperationDTO,
SpantypesSpanMapperOriginDTO,
SpantypesSpanMapperSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
export type MapperGroup = SpantypesSpanMapperGroupDTO;
@@ -11,21 +14,23 @@ export const FieldContext = SpantypesFieldContextDTO;
export type FieldContextValue = SpantypesFieldContextDTO;
export const MapperOperation = SpantypesSpanMapperOperationDTO;
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
export const MapperOrigin = SpantypesSpanMapperOriginDTO;
export type MapperOriginValue = SpantypesSpanMapperOriginDTO;
export type ConditionKey = SpantypesSpanMapperGroupConditionKeyDTO;
export type MapperDraftMode = 'add' | 'edit';
export interface SourceConfig {
key: string;
context: SpantypesFieldContextDTO;
operation: SpantypesSpanMapperOperationDTO;
}
// `priority` is left out: it is derived from list order when the draft is
// serialized.
export type SourceConfig = Omit<SpantypesSpanMapperSourceDTO, 'priority'>;
// Editable form state for a mapper. `sources` is ordered highest priority
// first; `fieldContext` is where the standardized target is written.
export interface MapperDraft {
id: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
fieldContext: FieldContextValue;
sources: SourceConfig[];
enabled: boolean;
}
@@ -33,26 +38,20 @@ export interface MapperDraft {
export interface GroupDraft {
id: string | null;
name: string;
attributes: string[];
resource: string[];
attributes: ConditionKey[];
resource: ConditionKey[];
enabled: boolean;
}
export interface DraftMapper {
// The editor tree identifies rows by `localId` so unsaved ones are addressable;
// `serverId` is null until the row has been persisted.
export type DraftMapper = Omit<MapperDraft, 'id'> & {
localId: string;
serverId: string | null;
name: string;
fieldContext: SpantypesFieldContextDTO;
sources: SourceConfig[];
enabled: boolean;
}
};
export interface DraftGroup {
export type DraftGroup = Omit<GroupDraft, 'id'> & {
localId: string;
serverId: string | null;
name: string;
attributes: string[];
resource: string[];
enabled: boolean;
mappers: DraftMapper[];
}
};

View File

@@ -1,12 +1,14 @@
import {
SpantypesPostableSpanMapperDTO,
SpantypesPostableSpanMapperGroupDTO,
SpantypesSpanMapperGroupConditionKeyDTO,
SpantypesUpdatableSpanMapperDTO,
SpantypesUpdatableSpanMapperGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { v4 as uuid } from 'uuid';
import {
ConditionKey,
DraftGroup,
DraftMapper,
FieldContext,
@@ -15,6 +17,7 @@ import {
MapperDraft,
MapperGroup,
MapperOperation,
MapperOrigin,
SourceConfig,
} from './types';
@@ -24,20 +27,36 @@ function genLocalId(prefix: 'group' | 'mapper'): string {
return `local-${prefix}-${uuid()}`;
}
// Trimmed, de-duplicated, non-empty keys preserving input order.
function cleanKeys(keys: string[]): string[] {
export function createConditionKey(value = ''): ConditionKey {
return { value, enabled: true, origin: MapperOrigin.user };
}
// Trimmed, de-duplicated, non-empty keys preserving input order. A shipped and
// a user key may share a value, so the origin is part of the identity.
function cleanKeys(keys: ConditionKey[]): ConditionKey[] {
const seen = new Set<string>();
const result: string[] = [];
const result: ConditionKey[] = [];
keys.forEach((raw) => {
const key = raw.trim();
if (key && !seen.has(key)) {
seen.add(key);
result.push(key);
const value = raw.value.trim();
const dedupeKey = `${raw.origin}:${value}`;
if (value && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...raw, value });
}
});
return result;
}
function fromConditionKeys(
keys: SpantypesSpanMapperGroupConditionKeyDTO[] | null | undefined,
): ConditionKey[] {
return (keys ?? []).map((key) => ({
value: key.value,
enabled: key.enabled,
origin: key.origin ?? MapperOrigin.user,
}));
}
// Source configs for a mapper, highest priority first (first match wins at
// evaluation time).
function getMapperSources(mapper: Mapper): SourceConfig[] {
@@ -48,6 +67,8 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
key: source.key,
context: source.context,
operation: source.operation,
enabled: source.enabled,
origin: source.origin ?? MapperOrigin.user,
}));
}
@@ -56,6 +77,8 @@ export function createEmptySource(): SourceConfig {
key: '',
context: FieldContext.attribute,
operation: MapperOperation.copy,
enabled: true,
origin: MapperOrigin.user,
};
}
@@ -72,7 +95,7 @@ function getCleanSources(draft: MapperDraft): SourceConfig[] {
const result: SourceConfig[] = [];
draft.sources.forEach((source) => {
const key = source.key.trim();
const dedupeKey = `${source.context}:${key}`;
const dedupeKey = `${source.origin}:${source.context}:${key}`;
if (key && !seen.has(dedupeKey)) {
seen.add(dedupeKey);
result.push({ ...source, key });
@@ -95,6 +118,8 @@ function buildSources(
context: source.context,
operation: source.operation,
priority: sources.length - index,
enabled: source.enabled,
origin: source.origin,
}));
}
@@ -123,7 +148,7 @@ export function buildUpdatableMapper(
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
attributes: [createConditionKey()],
resource: [],
enabled: true,
};
@@ -170,8 +195,8 @@ export function buildDraftGroup(
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
attributes: fromConditionKeys(group.condition?.attributes),
resource: fromConditionKeys(group.condition?.resource),
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
@@ -182,7 +207,8 @@ export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
attributes:
group.attributes.length > 0 ? group.attributes : [createConditionKey()],
resource: group.resource,
enabled: group.enabled,
};

View File

@@ -8,18 +8,13 @@ import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import Toolbar from 'container/Toolbar/Toolbar';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -28,7 +23,6 @@ import {
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -37,7 +31,7 @@ import {
tracesChangeViewAction,
tracesRunQueryAction,
tracesSaveViewAction,
} from 'pages/TracesExplorer/aiActions';
} from './aiActions';
import { Warning } from 'types/api';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -45,12 +39,10 @@ import {
explorerViewToPanelType,
getExplorerViewFromUrl,
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
import LeftToolbarActions from '../ToolbarActions/LeftToolbarActions';
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
import TableView from './TableView/TableView';
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
@@ -60,7 +52,6 @@ import './Explorer.styles.scss';
function Explorer(): JSX.Element {
const {
panelType,
updateAllQueriesOperators,
handleRunQuery,
stagedQuery,
@@ -72,20 +63,12 @@ function Explorer(): JSX.Element {
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { options } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
@@ -112,19 +95,24 @@ function Explorer(): JSX.Element {
const [warning, setWarning] = useState<Warning | undefined>();
const [isOpen, setOpen] = useState<boolean>(true);
const { startUnixMilli, endUnixMilli } = useSignalFieldApis();
// existingQuery is left unset so related values auto-extract from the current query
const quickFiltersFieldApis = useMemo(
() => ({ startUnixMilli, endUnixMilli }),
[startUnixMilli, endUnixMilli],
);
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueryAIWithType,
PANEL_TYPES.LIST,
DEFAULT_PANEL_TYPE,
DataSource.TRACES,
),
[updateAllQueriesOperators],
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
const handleChangeSelectedView = useCallback(
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
@@ -139,7 +127,7 @@ function Explorer(): JSX.Element {
},
[handleExplorerTabChange, handleSetConfig],
);
//TODO: check if we need to enable AI Assistant page actions on LLM o11y
// ─── AI Assistant page actions (only when license feature is on) ───────────
const aiActions = useMemo(
() =>
@@ -179,59 +167,6 @@ function Explorer(): JSX.Element {
usePageActions('traces-explorer', aiActions);
// ───────────────────────────────────────────────────────────────────────────
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueryAIWithType,
panelType || PANEL_TYPES.LIST,
),
[stagedQuery, panelType],
);
const handleExport = useCallback(
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
if (!dashboard || !panelType) {
return;
}
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
? panelType
: PANEL_TYPES.TIME_SERIES;
const widgetId = v4();
const query = getExportQueryData(
exportDefaultQuery,
panelTypeParam,
options,
);
logEvent('Traces Explorer: Add to dashboard successful', {
panelType,
isNewDashboard,
dashboardName: dashboard?.title,
});
const dashboardEditView = getExportToDashboardLink({
query,
panelType: panelTypeParam,
dashboardId: dashboard.id,
widgetId,
});
if (dashboardEditView) {
safeNavigate(dashboardEditView);
}
},
[
exportDefaultQuery,
panelType,
safeNavigate,
options,
getExportToDashboardLink,
],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
const logEventCalledRef = useRef(false);
@@ -260,8 +195,9 @@ function Explorer(): JSX.Element {
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
source={QuickFiltersSource.AI_OBSERVABILITY}
signal={SignalType.AI_OBSERVABILITY}
useFieldApis={quickFiltersFieldApis}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
@@ -354,14 +290,6 @@ function Explorer(): JSX.Element {
</div>
)}
</div>
<ExplorerOptionWrapper
disabled={!stagedQuery}
query={exportDefaultQuery}
sourcepage={DataSource.TRACES}
onExport={handleExport}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</Sentry.ErrorBoundary>

View File

@@ -12,25 +12,17 @@ import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import {
getTraceLink,
transformSpanRows,
} from 'container/TracesExplorer/ListView/utils';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
import { getTraceLink, transformSpanRows } from './utils';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -42,6 +34,7 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
defaultSelectedColumns,
@@ -79,14 +72,6 @@ function ListView({
loading: timeRangeUpdateLoading,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const { options, config } = useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
},
});
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
@@ -98,19 +83,6 @@ function ListView({
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
const queryKey = useMemo(
() => [
REACT_QUERY_KEY.GET_QUERY_RANGE,
@@ -120,7 +92,6 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
@@ -128,7 +99,6 @@ function ListView({
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
orderBy,
@@ -150,7 +120,7 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
selectColumns: defaultSelectedColumns,
},
},
ENTITY_VERSION_V5,
@@ -158,10 +128,7 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
},
);
@@ -186,28 +153,20 @@ function ListView({
[queryTableDataResult],
);
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
const fields = [
TIMESTAMP_FIELD,
...(options?.selectColumns ?? []).filter(
(field) => field.name !== TIMESTAMP_FIELD.name,
// TODO(ai-explorer): static columns until the preferences framework lands.
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(
() =>
[TIMESTAMP_FIELD, ...defaultSelectedColumns].map((field) =>
getFieldColumn(field),
),
];
return fields.map((field) => getFieldColumn(field));
}, [options?.selectColumns]);
[],
);
const rows = useMemo(
() => transformSpanRows(queryTableData),
[queryTableData],
);
const handleColumnOrderChange = useCallback(
(reordered: TableColumnDef<TracesTableRow>[]): void => {
config?.addColumn?.onReorder(reordered.map((column) => column.id));
},
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
@@ -235,15 +194,9 @@ function ListView({
/>
</div>
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}
totalCount={rows.length}
config={config}
perPageOptions={PER_PAGE_OPTIONS}
/>
</div>
@@ -251,6 +204,8 @@ function ListView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
respectColumnOrder
panelType="LIST"
getRowHref={getTraceLink}
isLoading={isLoading}
@@ -258,8 +213,6 @@ function ListView({
isError={isError}
error={error}
isFilterApplied={isFilterApplied}
onColumnOrderChange={handleColumnOrderChange}
onColumnRemove={config?.addColumn?.onRemove}
/>
</div>
);

View File

@@ -1,19 +1,41 @@
import type { TelemetryFieldKey } from 'api/v5/v5';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const defaultSelectedColumns: string[] = [
'service.name',
'name',
'duration_nano',
'http_method',
'response_status_code',
'timestamp',
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
// Pinned timestamp column
// The list query returns timestamp, trace_id and span_id whether or not they are selected.
export const TIMESTAMP_FIELD = {
name: 'timestamp',
fieldContext: 'span',
} as TelemetryFieldKey;
export const defaultSelectedColumns: TelemetryFieldKey[] = [
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{
name: 'name',
signal: 'traces',
fieldContext: 'span',
fieldDataType: 'string',
},
{
name: 'duration_nano',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'http_method',
signal: 'traces',
fieldContext: 'span',
},
{
name: 'response_status_code',
signal: 'traces',
fieldContext: 'span',
},
];
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -1,47 +1,8 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
@@ -60,95 +21,6 @@ export const getTraceLink = (record: Record<string, unknown>): string => {
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.

View File

@@ -4,8 +4,10 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const isRawQuery = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,

View File

@@ -107,7 +107,7 @@ function TableView({
dataSource={DataSource.TRACES}
data={data}
query={stagedQuery || initialQueriesMap.traces}
fileName="traces-table"
fileName="ai-traces-table"
/>
</div>
)}

View File

@@ -126,6 +126,7 @@ function TimeSeriesViewContainer({
dataSource={dataSource}
setWarning={setWarning}
allowExport
exportFileName="ai-traces-timeseries"
/>
</div>
);

View File

@@ -55,6 +55,9 @@ function TracesTable({
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
// Rows can land before the field keys, and mounting then renders a partial column set.
const canMountTable = !isError && !isLoading && data.length !== 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
@@ -83,7 +86,7 @@ function TracesTable({
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
{canMountTable && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { render, screen, userEvent } from 'tests/test-utils';
import { buildTraceViewColumns } from '../../TracesView/configs';
import TracesTable from '../TracesTable';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'start_time' },
]);
function RaceHarness(): JSX.Element {
const [columnsReady, setColumnsReady] = useState(false);
return (
<>
<button type="button" onClick={(): void => setColumnsReady(true)}>
columns-ready
</button>
<TracesTable
data={ROWS}
columns={columnsReady ? COLUMNS : []}
columnStorageKey={STORAGE_KEY}
respectColumnOrder
panelType="TRACE"
getRowHref={(): string => '/trace/abc'}
isLoading={!columnsReady}
isFetching={false}
isError={false}
error={null}
isFilterApplied={false}
/>
</>
);
}
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
describe('TracesTable column-init race', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
});
it('does not persist empty defaults when rows land before columns', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<RaceHarness />);
expect(screen.getByText(/pending_data_placeholder/i)).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
await user.click(screen.getByRole('button', { name: 'columns-ready' }));
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
});
});

View File

@@ -1,6 +1,13 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
// start/end/last_activity_time come from the per-trace query, unlike span timestamp.
export const TIMESTAMP_FIELD_NAMES = new Set([
'timestamp',
'start_time',
'end_time',
'last_activity_time',
]);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
@@ -13,6 +20,12 @@ export const STATUS_FIELD_NAMES = new Set([
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
// trace_/max_llm_duration_nano are trace-level durations the per-trace query computes.
export const DURATION_FIELD_NAMES = new Set([
'durationNano',
'duration_nano',
'trace_duration_nano',
'max_llm_duration_nano',
]);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -67,6 +67,7 @@ function TracesView({
onFieldsChange,
requiredFields,
isLoading: isColumnsLoading,
canPersistColumns,
} = useTraceViewColumns();
const {
@@ -168,9 +169,22 @@ function TracesView({
setOrderBy(value);
}, []);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(
() => ({ fieldsSelector: { value: selectedFields, onFieldsChange } }),
[selectedFields, onFieldsChange],
() =>
canPersistColumns
? { fieldsSelector: { value: selectedFields, onFieldsChange } }
: null,
[canPersistColumns, selectedFields, onFieldsChange],
);
// Rendering the pool unfiltered would surface columns the defaults keep hidden.
const tableColumns = useMemo(
() =>
canPersistColumns
? columns
: columns.filter((column) => column.defaultVisibility !== false),
[canPersistColumns, columns],
);
return (
@@ -207,8 +221,12 @@ function TracesView({
<TracesTable
data={rows}
columns={columns}
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
columns={tableColumns}
columnStorageKey={
canPersistColumns
? LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS
: undefined
}
respectColumnOrder
panelType="TRACE"
getRowHref={getTraceLink}

View File

@@ -0,0 +1,190 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useColumnStore } from 'components/TanStackTableView/useColumnStore';
import { LOCALSTORAGE } from 'constants/localStorage';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import TracesView from '../TracesView';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
const FIELD_KEYS_URL = `${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`;
const OPTIONS_TRIGGER = 'options_menu.options';
const ROWS = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'checkout',
root_span_name: 'HTTP GET',
trace_duration_nano: 55306000,
span_count: 8,
trace_id: '0000000000000000344ded1387b08a7e',
},
},
];
const mockRows = (): void => {
server.use(
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: {
type: 'trace',
data: { results: [{ queryName: 'A', rows: ROWS }] },
},
}),
),
),
);
};
const mockFieldKeys = (names: string[]): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: Object.fromEntries(
names.map((name) => [
name,
[
{
name,
fieldContext: TelemetrytypesFieldContextDTO.trace,
fieldDataType: TelemetrytypesFieldDataTypeDTO.float64,
},
],
]),
),
},
}),
),
),
);
};
const mockFieldKeysFailure = (): void => {
server.use(
rest.get(FIELD_KEYS_URL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
};
const persistedState = (): { hiddenColumnIds: string[] } | null => {
const raw = localStorage.getItem(PERSISTED_KEY);
return raw ? (JSON.parse(raw) as { hiddenColumnIds: string[] }) : null;
};
const renderTracesView = (): ReturnType<typeof render> =>
render(
<TracesView
isFilterApplied={false}
setWarning={jest.fn()}
setIsLoadingQueries={jest.fn()}
/>,
{},
{
initialRoute: '/llm-observability/traces',
queryBuilderOverrides: {
panelType: PANEL_TYPES.TRACE,
stagedQuery: initialQueryAIWithType,
currentQuery: initialQueryAIWithType,
} as never,
},
);
describe('TracesView column persistence', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
mockRows();
});
afterEach(() => {
server.resetHandlers();
});
// Rows are virtualised, so a mounted table stands in for "rows arrived".
const findTable = (): Promise<HTMLElement> => screen.findByRole('table');
it('seeds the persisted defaults once the field keys arrive', async () => {
mockFieldKeys(['llm_call_count', 'tool_call_count']);
renderTracesView();
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
});
it('persists nothing when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(persistedState()).toBeNull();
});
it('drops the column picker when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders only the default-visible columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {
const existing = {
hiddenColumnIds: ['trace:tool_call_count:float64', 'input', 'output'],
columnOrder: ['trace_id', 'resource:service.name'],
columnSizing: {},
};
localStorage.setItem(PERSISTED_KEY, JSON.stringify(existing));
mockFieldKeysFailure();
renderTracesView();
await findTable();
expect(persistedState()).toStrictEqual(existing);
});
});

View File

@@ -149,6 +149,62 @@ describe('useTraceViewColumns', () => {
);
});
describe('when the keys fetch fails', () => {
beforeEach(() => {
server.use(
rest.get(
`${ENVIRONMENT.baseURL}/api/v1/ai_observability/fields/keys`,
(_req, res, ctx) => res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
});
it('does not persist defaults', async () => {
await renderColumns();
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
expect(
localStorage.getItem(`@signoz/table-columns/${STORAGE_KEY}`),
).toBeNull();
});
it('reports the column state as not persistable', async () => {
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(false);
});
it('ignores a selection change instead of persisting a partial set', async () => {
const { result } = await renderColumns();
act(() => {
result.current.onFieldsChange([{ name: 'trace_id' }]);
});
expect(useColumnStore.getState().tables[STORAGE_KEY]).toBeUndefined();
});
it('seeds the defaults once a later fetch succeeds', async () => {
const { unmount } = await renderColumns();
unmount();
mockAggregateKeys(AGGREGATE_KEYS);
const { result } = await renderColumns();
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'service.name',
'root_span_name',
'trace_duration_nano',
'span_count',
'trace_id',
'llm_call_count',
'total_tokens',
'estimated_total_cost',
]);
});
});
it('hides the columns dropped from the selection', async () => {
const { result } = await renderColumns();

View File

@@ -8,7 +8,7 @@ export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Everything else starts hidden, including any aggregate the endpoint adds later. */
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',

View File

@@ -35,11 +35,17 @@ interface UseTraceViewColumns {
onFieldsChange: (next: TelemetryFieldKey[]) => void;
requiredFields: readonly string[];
isLoading: boolean;
/** False until the keys fetch lands; a partial set must not reach the persisted store. */
canPersistColumns: boolean;
}
// TODO(ai-explorer): browser-local only, unlike the list views' `?options=` columns.
export function useTraceViewColumns(): UseTraceViewColumns {
const { data: fetchedFields = [], isFetched } = useFieldKeysSuggestion(
const {
data: fetchedFields = [],
isFetched,
isSuccess,
} = useFieldKeysSuggestion(
{
...TRACE_VIEW_FIELD_KEYS,
signal: DATA_SOURCE_TO_SIGNAL[DataSource.TRACES],
@@ -60,10 +66,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Defaults from a partial column set would persist as the user's own choice.
useEffect(() => {
if (isFetched) {
if (isSuccess) {
initializeFromDefaults(STORAGE_KEY, columns);
}
}, [isFetched, columns]);
}, [isSuccess, columns]);
const hiddenColumnIds = useHiddenColumnIds(STORAGE_KEY);
const columnOrder = useColumnOrder(STORAGE_KEY);
@@ -83,6 +89,10 @@ export function useTraceViewColumns(): UseTraceViewColumns {
const onFieldsChange = useCallback(
(next: TelemetryFieldKey[]): void => {
if (!isSuccess) {
return;
}
const keptIds = new Set(next.map(columnIdOf));
columns.forEach((column) => {
@@ -96,7 +106,7 @@ export function useTraceViewColumns(): UseTraceViewColumns {
// Columns missing from the order sort last, so the visible ones suffice.
setColumnOrder(STORAGE_KEY, next.map(columnIdOf));
},
[columns],
[columns, isSuccess],
);
return {
@@ -105,5 +115,6 @@ export function useTraceViewColumns(): UseTraceViewColumns {
onFieldsChange,
requiredFields: [TRACE_ID_COLUMN_ID],
isLoading: !isFetched,
canPersistColumns: isSuccess,
};
}

View File

@@ -1,7 +1,17 @@
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
export const TOOLBAR_VIEWS = {
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
list: {
name: 'list',
label: 'List',
@@ -15,13 +25,6 @@ export const TOOLBAR_VIEWS = {
show: true,
key: 'timeseries',
},
trace: {
name: 'trace',
label: 'Trace',
disabled: false,
show: true,
key: 'trace',
},
table: {
name: 'table',
label: 'Table',

View File

@@ -1,6 +1,5 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
export const getListViewQuery = (
@@ -31,31 +30,3 @@ export const getListViewQuery = (
return query;
};
export const getQueryByPanelType = (
stagedQuery: Query,
panelType: PANEL_TYPES,
): Query => {
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
return getListViewQuery(stagedQuery);
}
return stagedQuery;
};
export const getExportQueryData = (
query: Query,
panelType: PANEL_TYPES,
options: OptionsQuery,
): Query => {
if (panelType === PANEL_TYPES.LIST) {
const updatedQuery = cloneDeep(query);
set(
updatedQuery,
'builder.queryData[0].selectColumns',
options.selectColumns,
);
return updatedQuery;
}
return query;
};

View File

@@ -1,19 +1,21 @@
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { ArrowUpToLine, Filter } from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { TOOLBAR_VIEW_CONFIG } from './toolbarViewsConfig';
import './ToolbarActions.styles.scss';
interface ToolbarViewItem {
name: string;
key: string;
show?: boolean;
disabled?: boolean;
}
interface LeftToolbarActionsProps {
items: any;
items: Record<string, ToolbarViewItem>;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
@@ -29,8 +31,6 @@ export default function LeftToolbarActions({
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
@@ -41,91 +41,34 @@ export default function LeftToolbarActions({
</Button>
</Tooltip>
)}
{/* Buttons render in the order the caller declares its views. */}
<div className="left-toolbar-query-actions">
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
{Object.values(items).map((item) => {
const config = TOOLBAR_VIEW_CONFIG[item?.key];
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
if (!item?.show || !config) {
return null;
}
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
const { icon: Icon, label, className, testId } = config;
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
return (
<Tooltip key={item.key} title={label}>
<Button
disabled={item.disabled}
className={cx(
className,
'explorer-view-option',
selectedView === item.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(item.key as ExplorerViews)}
>
<Icon size={14} data-testid={testId} />
{label}
</Button>
</Tooltip>
);
})}
</div>
</div>
);

View File

@@ -0,0 +1,47 @@
import {
Atom,
Binoculars,
SquareMousePointer,
Terminal,
} from '@signozhq/icons';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
export interface ToolbarViewConfig {
icon: typeof Atom;
label: string;
className: string;
testId: string;
}
export const TOOLBAR_VIEW_CONFIG: Record<string, ToolbarViewConfig> = {
[ExplorerViews.LIST]: {
icon: SquareMousePointer,
label: 'List View',
className: 'list-view-tab',
testId: 'search-view',
},
[ExplorerViews.TRACE]: {
icon: SquareMousePointer,
label: 'Trace View',
className: 'trace-view-tab',
testId: 'trace-view',
},
[ExplorerViews.TIMESERIES]: {
icon: Atom,
label: 'Time Series',
className: 'timeseries-view-tab',
testId: 'query-builder-view',
},
[ExplorerViews.CLICKHOUSE]: {
icon: Terminal,
label: 'Clickhouse',
className: 'clickhouse-view-tab',
testId: 'clickhouse-view',
},
[ExplorerViews.TABLE]: {
icon: Binoculars,
label: 'Table',
className: 'table-view-tab',
testId: 'query-builder-view-v2',
},
};

View File

@@ -64,6 +64,7 @@ function TimeSeriesView({
panelType = PANEL_TYPES.TIME_SERIES,
stackBarChart = false,
allowExport = false,
exportFileName,
onYAxisUnitChange,
}: TimeSeriesViewProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
@@ -270,7 +271,7 @@ function TimeSeriesView({
yAxisUnit={yAxisUnit}
data={data}
query={currentQuery}
fileName={`${dataSource}-timeseries`}
fileName={exportFileName ?? `${dataSource}-timeseries`}
/>
)}
</div>
@@ -339,6 +340,7 @@ interface TimeSeriesViewProps {
stackBarChart?: boolean;
// Opt-in: render the client-side export menu (Logs explorer for now).
allowExport?: boolean;
exportFileName?: string;
// Opt-in: render the y-axis unit selector in the header (views without their
// own selector, e.g. Logs). Metrics keeps its separate YAxisUnitSelector.
onYAxisUnitChange?: (value: string) => void;
@@ -351,6 +353,7 @@ TimeSeriesView.defaultProps = {
setWarning: undefined,
panelType: PANEL_TYPES.TIME_SERIES,
stackBarChart: false,
exportFileName: undefined,
};
export default TimeSeriesView;

View File

@@ -0,0 +1,61 @@
import {
QueryKey,
useQuery,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { ErrorType } from 'api/generatedAPIInstance';
import {
RenderErrorResponseDTO,
TelemetrytypesTelemetryFieldValuesDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getFieldValueSuggestions } from 'api/querySuggestions/getFieldValueSuggestions';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
FieldValuesConfig,
FieldValuesResponse,
} from 'api/querySuggestions/types';
import { BuilderQueryType } from 'types/api/v5/queryRange';
export type FieldValuesQueryOptions = UseQueryOptions<
FieldValuesResponse,
ErrorType<RenderErrorResponseDTO>,
TelemetrytypesTelemetryFieldValuesDTO
> & { queryKey: QueryKey };
const EMPTY_FIELD_VALUES: TelemetrytypesTelemetryFieldValuesDTO = {};
export const toFieldValues = (
res: FieldValuesResponse | undefined,
): TelemetrytypesTelemetryFieldValuesDTO =>
res?.data?.values ?? EMPTY_FIELD_VALUES;
export const getFieldValuesQueryOptions = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
): FieldValuesQueryOptions => ({
queryKey: [
REACT_QUERY_KEY.FIELD_VALUES_SUGGESTION,
builderQueryType,
fieldValuesConfig,
],
queryFn: ({ signal }): Promise<FieldValuesResponse> =>
getFieldValueSuggestions(fieldValuesConfig, builderQueryType, signal),
select: toFieldValues,
cacheTime: FIELD_API_CACHE_TIME,
keepPreviousData: true,
});
export const useFieldValuesSuggestion = (
fieldValuesConfig: FieldValuesConfig,
builderQueryType?: BuilderQueryType,
options?: Pick<FieldValuesQueryOptions, 'enabled'>,
): UseQueryResult<
TelemetrytypesTelemetryFieldValuesDTO,
ErrorType<RenderErrorResponseDTO>
> =>
useQuery({
...getFieldValuesQueryOptions(fieldValuesConfig, builderQueryType),
...options,
});

View File

@@ -61,6 +61,10 @@ type Alertmanager interface {
// DeleteChannelByID deletes a channel for the organization.
DeleteChannelByID(context.Context, string, valuer.UUID) error
// RepairNotificationChannel diagnoses a stored channel v2 cannot read and
// reports the fitting action, applying it only when apply is set.
RepairNotificationChannel(context.Context, string, valuer.UUID, bool) (*alertmanagertypes.ChannelRepair, error)
// Config returns the alertmanagerserver configuration.
Config() alertmanagerserver.Config

View File

@@ -21,10 +21,19 @@ func NewMockAlertmanager(t interface {
mock.TestingT
Cleanup(func())
}) *MockAlertmanager {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock := &MockAlertmanager{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
t.Cleanup(func() {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock.AssertExpectations(t)
})
return mock
}
@@ -78,7 +87,7 @@ type MockAlertmanager_Collect_Call struct {
// Collect is a helper method to define mock.On call
// - context1 context.Context
// - uUID valuer.UUID
func (_e *MockAlertmanager_Expecter) Collect(context1 interface{}, uUID interface{}) *MockAlertmanager_Collect_Call {
func (_e *MockAlertmanager_Expecter) Collect(context1 any, uUID any) *MockAlertmanager_Collect_Call {
return &MockAlertmanager_Collect_Call{Call: _e.mock.On("Collect", context1, uUID)}
}
@@ -100,8 +109,8 @@ func (_c *MockAlertmanager_Collect_Call) Run(run func(context1 context.Context,
return _c
}
func (_c *MockAlertmanager_Collect_Call) Return(stringToV map[string]any, err error) *MockAlertmanager_Collect_Call {
_c.Call.Return(stringToV, err)
func (_c *MockAlertmanager_Collect_Call) Return(stringToAnyMoqParam map[string]any, err error) *MockAlertmanager_Collect_Call {
_c.Call.Return(stringToAnyMoqParam, err)
return _c
}
@@ -191,7 +200,7 @@ type MockAlertmanager_CreateChannel_Call struct {
// - context1 context.Context
// - s string
// - receiver *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, receiver interface{}) *MockAlertmanager_CreateChannel_Call {
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 any, s any, receiver any) *MockAlertmanager_CreateChannel_Call {
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, receiver)}
}
@@ -254,7 +263,7 @@ type MockAlertmanager_CreateInhibitRules_Call struct {
// - ctx context.Context
// - orgID valuer.UUID
// - rules []config.InhibitRule
func (_e *MockAlertmanager_Expecter) CreateInhibitRules(ctx interface{}, orgID interface{}, rules interface{}) *MockAlertmanager_CreateInhibitRules_Call {
func (_e *MockAlertmanager_Expecter) CreateInhibitRules(ctx any, orgID any, rules any) *MockAlertmanager_CreateInhibitRules_Call {
return &MockAlertmanager_CreateInhibitRules_Call{Call: _e.mock.On("CreateInhibitRules", ctx, orgID, rules)}
}
@@ -328,7 +337,7 @@ type MockAlertmanager_CreateNotificationChannel_Call struct {
// - context1 context.Context
// - s string
// - postableNotificationChannel alertmanagertypes.PostableNotificationChannel
func (_e *MockAlertmanager_Expecter) CreateNotificationChannel(context1 interface{}, s interface{}, postableNotificationChannel interface{}) *MockAlertmanager_CreateNotificationChannel_Call {
func (_e *MockAlertmanager_Expecter) CreateNotificationChannel(context1 any, s any, postableNotificationChannel any) *MockAlertmanager_CreateNotificationChannel_Call {
return &MockAlertmanager_CreateNotificationChannel_Call{Call: _e.mock.On("CreateNotificationChannel", context1, s, postableNotificationChannel)}
}
@@ -401,7 +410,7 @@ type MockAlertmanager_CreateRoutePolicies_Call struct {
// CreateRoutePolicies is a helper method to define mock.On call
// - ctx context.Context
// - routeRequests []*alertmanagertypes.PostableRoutePolicy
func (_e *MockAlertmanager_Expecter) CreateRoutePolicies(ctx interface{}, routeRequests interface{}) *MockAlertmanager_CreateRoutePolicies_Call {
func (_e *MockAlertmanager_Expecter) CreateRoutePolicies(ctx any, routeRequests any) *MockAlertmanager_CreateRoutePolicies_Call {
return &MockAlertmanager_CreateRoutePolicies_Call{Call: _e.mock.On("CreateRoutePolicies", ctx, routeRequests)}
}
@@ -469,7 +478,7 @@ type MockAlertmanager_CreateRoutePolicy_Call struct {
// CreateRoutePolicy is a helper method to define mock.On call
// - ctx context.Context
// - route *alertmanagertypes.PostableRoutePolicy
func (_e *MockAlertmanager_Expecter) CreateRoutePolicy(ctx interface{}, route interface{}) *MockAlertmanager_CreateRoutePolicy_Call {
func (_e *MockAlertmanager_Expecter) CreateRoutePolicy(ctx any, route any) *MockAlertmanager_CreateRoutePolicy_Call {
return &MockAlertmanager_CreateRoutePolicy_Call{Call: _e.mock.On("CreateRoutePolicy", ctx, route)}
}
@@ -527,7 +536,7 @@ type MockAlertmanager_DeleteAllInhibitRulesByRuleId_Call struct {
// - ctx context.Context
// - orgID valuer.UUID
// - ruleId string
func (_e *MockAlertmanager_Expecter) DeleteAllInhibitRulesByRuleId(ctx interface{}, orgID interface{}, ruleId interface{}) *MockAlertmanager_DeleteAllInhibitRulesByRuleId_Call {
func (_e *MockAlertmanager_Expecter) DeleteAllInhibitRulesByRuleId(ctx any, orgID any, ruleId any) *MockAlertmanager_DeleteAllInhibitRulesByRuleId_Call {
return &MockAlertmanager_DeleteAllInhibitRulesByRuleId_Call{Call: _e.mock.On("DeleteAllInhibitRulesByRuleId", ctx, orgID, ruleId)}
}
@@ -589,7 +598,7 @@ type MockAlertmanager_DeleteAllRoutePoliciesByRuleId_Call struct {
// DeleteAllRoutePoliciesByRuleId is a helper method to define mock.On call
// - ctx context.Context
// - ruleId string
func (_e *MockAlertmanager_Expecter) DeleteAllRoutePoliciesByRuleId(ctx interface{}, ruleId interface{}) *MockAlertmanager_DeleteAllRoutePoliciesByRuleId_Call {
func (_e *MockAlertmanager_Expecter) DeleteAllRoutePoliciesByRuleId(ctx any, ruleId any) *MockAlertmanager_DeleteAllRoutePoliciesByRuleId_Call {
return &MockAlertmanager_DeleteAllRoutePoliciesByRuleId_Call{Call: _e.mock.On("DeleteAllRoutePoliciesByRuleId", ctx, ruleId)}
}
@@ -647,7 +656,7 @@ type MockAlertmanager_DeleteChannelByID_Call struct {
// - context1 context.Context
// - s string
// - uUID valuer.UUID
func (_e *MockAlertmanager_Expecter) DeleteChannelByID(context1 interface{}, s interface{}, uUID interface{}) *MockAlertmanager_DeleteChannelByID_Call {
func (_e *MockAlertmanager_Expecter) DeleteChannelByID(context1 any, s any, uUID any) *MockAlertmanager_DeleteChannelByID_Call {
return &MockAlertmanager_DeleteChannelByID_Call{Call: _e.mock.On("DeleteChannelByID", context1, s, uUID)}
}
@@ -710,7 +719,7 @@ type MockAlertmanager_DeleteNotificationConfig_Call struct {
// - ctx context.Context
// - orgID valuer.UUID
// - ruleId string
func (_e *MockAlertmanager_Expecter) DeleteNotificationConfig(ctx interface{}, orgID interface{}, ruleId interface{}) *MockAlertmanager_DeleteNotificationConfig_Call {
func (_e *MockAlertmanager_Expecter) DeleteNotificationConfig(ctx any, orgID any, ruleId any) *MockAlertmanager_DeleteNotificationConfig_Call {
return &MockAlertmanager_DeleteNotificationConfig_Call{Call: _e.mock.On("DeleteNotificationConfig", ctx, orgID, ruleId)}
}
@@ -772,7 +781,7 @@ type MockAlertmanager_DeleteRoutePolicyByID_Call struct {
// DeleteRoutePolicyByID is a helper method to define mock.On call
// - ctx context.Context
// - routeID string
func (_e *MockAlertmanager_Expecter) DeleteRoutePolicyByID(ctx interface{}, routeID interface{}) *MockAlertmanager_DeleteRoutePolicyByID_Call {
func (_e *MockAlertmanager_Expecter) DeleteRoutePolicyByID(ctx any, routeID any) *MockAlertmanager_DeleteRoutePolicyByID_Call {
return &MockAlertmanager_DeleteRoutePolicyByID_Call{Call: _e.mock.On("DeleteRoutePolicyByID", ctx, routeID)}
}
@@ -841,7 +850,7 @@ type MockAlertmanager_GetAlerts_Call struct {
// - context1 context.Context
// - s string
// - gettableAlertsParams alertmanagertypes.GettableAlertsParams
func (_e *MockAlertmanager_Expecter) GetAlerts(context1 interface{}, s interface{}, gettableAlertsParams interface{}) *MockAlertmanager_GetAlerts_Call {
func (_e *MockAlertmanager_Expecter) GetAlerts(context1 any, s any, gettableAlertsParams any) *MockAlertmanager_GetAlerts_Call {
return &MockAlertmanager_GetAlerts_Call{Call: _e.mock.On("GetAlerts", context1, s, gettableAlertsParams)}
}
@@ -868,8 +877,8 @@ func (_c *MockAlertmanager_GetAlerts_Call) Run(run func(context1 context.Context
return _c
}
func (_c *MockAlertmanager_GetAlerts_Call) Return(v alertmanagertypes.DeprecatedGettableAlerts, err error) *MockAlertmanager_GetAlerts_Call {
_c.Call.Return(v, err)
func (_c *MockAlertmanager_GetAlerts_Call) Return(deprecatedGettableAlerts alertmanagertypes.DeprecatedGettableAlerts, err error) *MockAlertmanager_GetAlerts_Call {
_c.Call.Return(deprecatedGettableAlerts, err)
return _c
}
@@ -913,7 +922,7 @@ type MockAlertmanager_GetAllRoutePolicies_Call struct {
// GetAllRoutePolicies is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockAlertmanager_Expecter) GetAllRoutePolicies(ctx interface{}) *MockAlertmanager_GetAllRoutePolicies_Call {
func (_e *MockAlertmanager_Expecter) GetAllRoutePolicies(ctx any) *MockAlertmanager_GetAllRoutePolicies_Call {
return &MockAlertmanager_GetAllRoutePolicies_Call{Call: _e.mock.On("GetAllRoutePolicies", ctx)}
}
@@ -977,7 +986,7 @@ type MockAlertmanager_GetChannelByID_Call struct {
// - context1 context.Context
// - s string
// - uUID valuer.UUID
func (_e *MockAlertmanager_Expecter) GetChannelByID(context1 interface{}, s interface{}, uUID interface{}) *MockAlertmanager_GetChannelByID_Call {
func (_e *MockAlertmanager_Expecter) GetChannelByID(context1 any, s any, uUID any) *MockAlertmanager_GetChannelByID_Call {
return &MockAlertmanager_GetChannelByID_Call{Call: _e.mock.On("GetChannelByID", context1, s, uUID)}
}
@@ -1050,7 +1059,7 @@ type MockAlertmanager_GetConfig_Call struct {
// GetConfig is a helper method to define mock.On call
// - context1 context.Context
// - s string
func (_e *MockAlertmanager_Expecter) GetConfig(context1 interface{}, s interface{}) *MockAlertmanager_GetConfig_Call {
func (_e *MockAlertmanager_Expecter) GetConfig(context1 any, s any) *MockAlertmanager_GetConfig_Call {
return &MockAlertmanager_GetConfig_Call{Call: _e.mock.On("GetConfig", context1, s)}
}
@@ -1118,7 +1127,7 @@ type MockAlertmanager_GetRoutePolicyByID_Call struct {
// GetRoutePolicyByID is a helper method to define mock.On call
// - ctx context.Context
// - routeID string
func (_e *MockAlertmanager_Expecter) GetRoutePolicyByID(ctx interface{}, routeID interface{}) *MockAlertmanager_GetRoutePolicyByID_Call {
func (_e *MockAlertmanager_Expecter) GetRoutePolicyByID(ctx any, routeID any) *MockAlertmanager_GetRoutePolicyByID_Call {
return &MockAlertmanager_GetRoutePolicyByID_Call{Call: _e.mock.On("GetRoutePolicyByID", ctx, routeID)}
}
@@ -1185,7 +1194,7 @@ type MockAlertmanager_ListAllChannels_Call struct {
// ListAllChannels is a helper method to define mock.On call
// - context1 context.Context
func (_e *MockAlertmanager_Expecter) ListAllChannels(context1 interface{}) *MockAlertmanager_ListAllChannels_Call {
func (_e *MockAlertmanager_Expecter) ListAllChannels(context1 any) *MockAlertmanager_ListAllChannels_Call {
return &MockAlertmanager_ListAllChannels_Call{Call: _e.mock.On("ListAllChannels", context1)}
}
@@ -1248,7 +1257,7 @@ type MockAlertmanager_ListChannels_Call struct {
// ListChannels is a helper method to define mock.On call
// - context1 context.Context
// - s string
func (_e *MockAlertmanager_Expecter) ListChannels(context1 interface{}, s interface{}) *MockAlertmanager_ListChannels_Call {
func (_e *MockAlertmanager_Expecter) ListChannels(context1 any, s any) *MockAlertmanager_ListChannels_Call {
return &MockAlertmanager_ListChannels_Call{Call: _e.mock.On("ListChannels", context1, s)}
}
@@ -1317,7 +1326,7 @@ type MockAlertmanager_ListNotificationChannels_Call struct {
// - context1 context.Context
// - s string
// - listChannelsParams *alertmanagertypes.ListChannelsParams
func (_e *MockAlertmanager_Expecter) ListNotificationChannels(context1 interface{}, s interface{}, listChannelsParams interface{}) *MockAlertmanager_ListNotificationChannels_Call {
func (_e *MockAlertmanager_Expecter) ListNotificationChannels(context1 any, s any, listChannelsParams any) *MockAlertmanager_ListNotificationChannels_Call {
return &MockAlertmanager_ListNotificationChannels_Call{Call: _e.mock.On("ListNotificationChannels", context1, s, listChannelsParams)}
}
@@ -1355,8 +1364,8 @@ func (_c *MockAlertmanager_ListNotificationChannels_Call) RunAndReturn(run func(
}
// PutAlerts provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) PutAlerts(context1 context.Context, s string, v alertmanagertypes.PostableAlerts) error {
ret := _mock.Called(context1, s, v)
func (_mock *MockAlertmanager) PutAlerts(context1 context.Context, s string, postableAlerts alertmanagertypes.PostableAlerts) error {
ret := _mock.Called(context1, s, postableAlerts)
if len(ret) == 0 {
panic("no return value specified for PutAlerts")
@@ -1364,7 +1373,7 @@ func (_mock *MockAlertmanager) PutAlerts(context1 context.Context, s string, v a
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.PostableAlerts) error); ok {
r0 = returnFunc(context1, s, v)
r0 = returnFunc(context1, s, postableAlerts)
} else {
r0 = ret.Error(0)
}
@@ -1379,12 +1388,12 @@ type MockAlertmanager_PutAlerts_Call struct {
// PutAlerts is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - v alertmanagertypes.PostableAlerts
func (_e *MockAlertmanager_Expecter) PutAlerts(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_PutAlerts_Call {
return &MockAlertmanager_PutAlerts_Call{Call: _e.mock.On("PutAlerts", context1, s, v)}
// - postableAlerts alertmanagertypes.PostableAlerts
func (_e *MockAlertmanager_Expecter) PutAlerts(context1 any, s any, postableAlerts any) *MockAlertmanager_PutAlerts_Call {
return &MockAlertmanager_PutAlerts_Call{Call: _e.mock.On("PutAlerts", context1, s, postableAlerts)}
}
func (_c *MockAlertmanager_PutAlerts_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.PostableAlerts)) *MockAlertmanager_PutAlerts_Call {
func (_c *MockAlertmanager_PutAlerts_Call) Run(run func(context1 context.Context, s string, postableAlerts alertmanagertypes.PostableAlerts)) *MockAlertmanager_PutAlerts_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -1412,7 +1421,87 @@ func (_c *MockAlertmanager_PutAlerts_Call) Return(err error) *MockAlertmanager_P
return _c
}
func (_c *MockAlertmanager_PutAlerts_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.PostableAlerts) error) *MockAlertmanager_PutAlerts_Call {
func (_c *MockAlertmanager_PutAlerts_Call) RunAndReturn(run func(context1 context.Context, s string, postableAlerts alertmanagertypes.PostableAlerts) error) *MockAlertmanager_PutAlerts_Call {
_c.Call.Return(run)
return _c
}
// RepairNotificationChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) RepairNotificationChannel(context1 context.Context, s string, uUID valuer.UUID, b bool) (*alertmanagertypes.ChannelRepair, error) {
ret := _mock.Called(context1, s, uUID, b)
if len(ret) == 0 {
panic("no return value specified for RepairNotificationChannel")
}
var r0 *alertmanagertypes.ChannelRepair
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID, bool) (*alertmanagertypes.ChannelRepair, error)); ok {
return returnFunc(context1, s, uUID, b)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID, bool) *alertmanagertypes.ChannelRepair); ok {
r0 = returnFunc(context1, s, uUID, b)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.ChannelRepair)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, valuer.UUID, bool) error); ok {
r1 = returnFunc(context1, s, uUID, b)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAlertmanager_RepairNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepairNotificationChannel'
type MockAlertmanager_RepairNotificationChannel_Call struct {
*mock.Call
}
// RepairNotificationChannel is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - uUID valuer.UUID
// - b bool
func (_e *MockAlertmanager_Expecter) RepairNotificationChannel(context1 any, s any, uUID any, b any) *MockAlertmanager_RepairNotificationChannel_Call {
return &MockAlertmanager_RepairNotificationChannel_Call{Call: _e.mock.On("RepairNotificationChannel", context1, s, uUID, b)}
}
func (_c *MockAlertmanager_RepairNotificationChannel_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID, b bool)) *MockAlertmanager_RepairNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 valuer.UUID
if args[2] != nil {
arg2 = args[2].(valuer.UUID)
}
var arg3 bool
if args[3] != nil {
arg3 = args[3].(bool)
}
run(
arg0,
arg1,
arg2,
arg3,
)
})
return _c
}
func (_c *MockAlertmanager_RepairNotificationChannel_Call) Return(channelRepair *alertmanagertypes.ChannelRepair, err error) *MockAlertmanager_RepairNotificationChannel_Call {
_c.Call.Return(channelRepair, err)
return _c
}
func (_c *MockAlertmanager_RepairNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID, b bool) (*alertmanagertypes.ChannelRepair, error)) *MockAlertmanager_RepairNotificationChannel_Call {
_c.Call.Return(run)
return _c
}
@@ -1442,7 +1531,7 @@ type MockAlertmanager_SetConfig_Call struct {
// SetConfig is a helper method to define mock.On call
// - context1 context.Context
// - config1 *alertmanagertypes.Config
func (_e *MockAlertmanager_Expecter) SetConfig(context1 interface{}, config1 interface{}) *MockAlertmanager_SetConfig_Call {
func (_e *MockAlertmanager_Expecter) SetConfig(context1 any, config1 any) *MockAlertmanager_SetConfig_Call {
return &MockAlertmanager_SetConfig_Call{Call: _e.mock.On("SetConfig", context1, config1)}
}
@@ -1499,7 +1588,7 @@ type MockAlertmanager_SetDefaultConfig_Call struct {
// SetDefaultConfig is a helper method to define mock.On call
// - context1 context.Context
// - s string
func (_e *MockAlertmanager_Expecter) SetDefaultConfig(context1 interface{}, s interface{}) *MockAlertmanager_SetDefaultConfig_Call {
func (_e *MockAlertmanager_Expecter) SetDefaultConfig(context1 any, s any) *MockAlertmanager_SetDefaultConfig_Call {
return &MockAlertmanager_SetDefaultConfig_Call{Call: _e.mock.On("SetDefaultConfig", context1, s)}
}
@@ -1558,7 +1647,7 @@ type MockAlertmanager_SetNotificationConfig_Call struct {
// - orgID valuer.UUID
// - ruleId string
// - config1 *alertmanagertypes.NotificationConfig
func (_e *MockAlertmanager_Expecter) SetNotificationConfig(ctx interface{}, orgID interface{}, ruleId interface{}, config1 interface{}) *MockAlertmanager_SetNotificationConfig_Call {
func (_e *MockAlertmanager_Expecter) SetNotificationConfig(ctx any, orgID any, ruleId any, config1 any) *MockAlertmanager_SetNotificationConfig_Call {
return &MockAlertmanager_SetNotificationConfig_Call{Call: _e.mock.On("SetNotificationConfig", ctx, orgID, ruleId, config1)}
}
@@ -1624,7 +1713,7 @@ type MockAlertmanager_Start_Call struct {
// Start is a helper method to define mock.On call
// - context1 context.Context
func (_e *MockAlertmanager_Expecter) Start(context1 interface{}) *MockAlertmanager_Start_Call {
func (_e *MockAlertmanager_Expecter) Start(context1 any) *MockAlertmanager_Start_Call {
return &MockAlertmanager_Start_Call{Call: _e.mock.On("Start", context1)}
}
@@ -1675,7 +1764,7 @@ type MockAlertmanager_Stop_Call struct {
// Stop is a helper method to define mock.On call
// - context1 context.Context
func (_e *MockAlertmanager_Expecter) Stop(context1 interface{}) *MockAlertmanager_Stop_Call {
func (_e *MockAlertmanager_Expecter) Stop(context1 any) *MockAlertmanager_Stop_Call {
return &MockAlertmanager_Stop_Call{Call: _e.mock.On("Stop", context1)}
}
@@ -1729,7 +1818,7 @@ type MockAlertmanager_TestAlert_Call struct {
// - orgID string
// - ruleID string
// - receiversMap map[*alertmanagertypes.PostableAlert][]string
func (_e *MockAlertmanager_Expecter) TestAlert(ctx interface{}, orgID interface{}, ruleID interface{}, receiversMap interface{}) *MockAlertmanager_TestAlert_Call {
func (_e *MockAlertmanager_Expecter) TestAlert(ctx any, orgID any, ruleID any, receiversMap any) *MockAlertmanager_TestAlert_Call {
return &MockAlertmanager_TestAlert_Call{Call: _e.mock.On("TestAlert", ctx, orgID, ruleID, receiversMap)}
}
@@ -1797,7 +1886,7 @@ type MockAlertmanager_TestNotificationChannel_Call struct {
// - context1 context.Context
// - s string
// - testableNotificationChannel alertmanagertypes.TestableNotificationChannel
func (_e *MockAlertmanager_Expecter) TestNotificationChannel(context1 interface{}, s interface{}, testableNotificationChannel interface{}) *MockAlertmanager_TestNotificationChannel_Call {
func (_e *MockAlertmanager_Expecter) TestNotificationChannel(context1 any, s any, testableNotificationChannel any) *MockAlertmanager_TestNotificationChannel_Call {
return &MockAlertmanager_TestNotificationChannel_Call{Call: _e.mock.On("TestNotificationChannel", context1, s, testableNotificationChannel)}
}
@@ -1860,7 +1949,7 @@ type MockAlertmanager_TestReceiver_Call struct {
// - context1 context.Context
// - s string
// - receiver *alertmanagertypes.Receiver
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, receiver interface{}) *MockAlertmanager_TestReceiver_Call {
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 any, s any, receiver any) *MockAlertmanager_TestReceiver_Call {
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, receiver)}
}
@@ -1923,7 +2012,7 @@ type MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call struct {
// - ctx context.Context
// - ruleId string
// - routes []*alertmanagertypes.PostableRoutePolicy
func (_e *MockAlertmanager_Expecter) UpdateAllRoutePoliciesByRuleId(ctx interface{}, ruleId interface{}, routes interface{}) *MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call {
func (_e *MockAlertmanager_Expecter) UpdateAllRoutePoliciesByRuleId(ctx any, ruleId any, routes any) *MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call {
return &MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call{Call: _e.mock.On("UpdateAllRoutePoliciesByRuleId", ctx, ruleId, routes)}
}
@@ -1987,7 +2076,7 @@ type MockAlertmanager_UpdateChannelByReceiverAndID_Call struct {
// - s string
// - receiver *alertmanagertypes.Receiver
// - uUID valuer.UUID
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, receiver interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 any, s any, receiver any, uUID any) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, receiver, uUID)}
}
@@ -2067,7 +2156,7 @@ type MockAlertmanager_UpdateNotificationChannel_Call struct {
// - s string
// - uUID valuer.UUID
// - updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel
func (_e *MockAlertmanager_Expecter) UpdateNotificationChannel(context1 interface{}, s interface{}, uUID interface{}, updatableNotificationChannel interface{}) *MockAlertmanager_UpdateNotificationChannel_Call {
func (_e *MockAlertmanager_Expecter) UpdateNotificationChannel(context1 any, s any, uUID any, updatableNotificationChannel any) *MockAlertmanager_UpdateNotificationChannel_Call {
return &MockAlertmanager_UpdateNotificationChannel_Call{Call: _e.mock.On("UpdateNotificationChannel", context1, s, uUID, updatableNotificationChannel)}
}
@@ -2146,7 +2235,7 @@ type MockAlertmanager_UpdateRoutePolicyByID_Call struct {
// - ctx context.Context
// - routeID string
// - route *alertmanagertypes.PostableRoutePolicy
func (_e *MockAlertmanager_Expecter) UpdateRoutePolicyByID(ctx interface{}, routeID interface{}, route interface{}) *MockAlertmanager_UpdateRoutePolicyByID_Call {
func (_e *MockAlertmanager_Expecter) UpdateRoutePolicyByID(ctx any, routeID any, route any) *MockAlertmanager_UpdateRoutePolicyByID_Call {
return &MockAlertmanager_UpdateRoutePolicyByID_Call{Call: _e.mock.On("UpdateRoutePolicyByID", ctx, routeID, route)}
}
@@ -2189,10 +2278,19 @@ func NewMockHandler(t interface {
mock.TestingT
Cleanup(func())
}) *MockHandler {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock := &MockHandler{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
t.Cleanup(func() {
if helper, ok := t.(interface{ Helper() }); ok {
helper.Helper()
}
mock.AssertExpectations(t)
})
return mock
}
@@ -2224,7 +2322,7 @@ type MockHandler_CreateChannel_Call struct {
// CreateChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) CreateChannel(responseWriter interface{}, request interface{}) *MockHandler_CreateChannel_Call {
func (_e *MockHandler_Expecter) CreateChannel(responseWriter any, request any) *MockHandler_CreateChannel_Call {
return &MockHandler_CreateChannel_Call{Call: _e.mock.On("CreateChannel", responseWriter, request)}
}
@@ -2270,7 +2368,7 @@ type MockHandler_CreateNotificationChannel_Call struct {
// CreateNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) CreateNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_CreateNotificationChannel_Call {
func (_e *MockHandler_Expecter) CreateNotificationChannel(responseWriter any, request any) *MockHandler_CreateNotificationChannel_Call {
return &MockHandler_CreateNotificationChannel_Call{Call: _e.mock.On("CreateNotificationChannel", responseWriter, request)}
}
@@ -2316,7 +2414,7 @@ type MockHandler_CreateRoutePolicy_Call struct {
// CreateRoutePolicy is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) CreateRoutePolicy(responseWriter interface{}, request interface{}) *MockHandler_CreateRoutePolicy_Call {
func (_e *MockHandler_Expecter) CreateRoutePolicy(responseWriter any, request any) *MockHandler_CreateRoutePolicy_Call {
return &MockHandler_CreateRoutePolicy_Call{Call: _e.mock.On("CreateRoutePolicy", responseWriter, request)}
}
@@ -2362,7 +2460,7 @@ type MockHandler_DeleteChannelByID_Call struct {
// DeleteChannelByID is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) DeleteChannelByID(responseWriter interface{}, request interface{}) *MockHandler_DeleteChannelByID_Call {
func (_e *MockHandler_Expecter) DeleteChannelByID(responseWriter any, request any) *MockHandler_DeleteChannelByID_Call {
return &MockHandler_DeleteChannelByID_Call{Call: _e.mock.On("DeleteChannelByID", responseWriter, request)}
}
@@ -2408,7 +2506,7 @@ type MockHandler_DeleteNotificationChannel_Call struct {
// DeleteNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) DeleteNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_DeleteNotificationChannel_Call {
func (_e *MockHandler_Expecter) DeleteNotificationChannel(responseWriter any, request any) *MockHandler_DeleteNotificationChannel_Call {
return &MockHandler_DeleteNotificationChannel_Call{Call: _e.mock.On("DeleteNotificationChannel", responseWriter, request)}
}
@@ -2454,7 +2552,7 @@ type MockHandler_DeleteRoutePolicyByID_Call struct {
// DeleteRoutePolicyByID is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) DeleteRoutePolicyByID(responseWriter interface{}, request interface{}) *MockHandler_DeleteRoutePolicyByID_Call {
func (_e *MockHandler_Expecter) DeleteRoutePolicyByID(responseWriter any, request any) *MockHandler_DeleteRoutePolicyByID_Call {
return &MockHandler_DeleteRoutePolicyByID_Call{Call: _e.mock.On("DeleteRoutePolicyByID", responseWriter, request)}
}
@@ -2500,7 +2598,7 @@ type MockHandler_GetAlerts_Call struct {
// GetAlerts is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) GetAlerts(responseWriter interface{}, request interface{}) *MockHandler_GetAlerts_Call {
func (_e *MockHandler_Expecter) GetAlerts(responseWriter any, request any) *MockHandler_GetAlerts_Call {
return &MockHandler_GetAlerts_Call{Call: _e.mock.On("GetAlerts", responseWriter, request)}
}
@@ -2546,7 +2644,7 @@ type MockHandler_GetAllRoutePolicies_Call struct {
// GetAllRoutePolicies is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) GetAllRoutePolicies(responseWriter interface{}, request interface{}) *MockHandler_GetAllRoutePolicies_Call {
func (_e *MockHandler_Expecter) GetAllRoutePolicies(responseWriter any, request any) *MockHandler_GetAllRoutePolicies_Call {
return &MockHandler_GetAllRoutePolicies_Call{Call: _e.mock.On("GetAllRoutePolicies", responseWriter, request)}
}
@@ -2592,7 +2690,7 @@ type MockHandler_GetChannelByID_Call struct {
// GetChannelByID is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) GetChannelByID(responseWriter interface{}, request interface{}) *MockHandler_GetChannelByID_Call {
func (_e *MockHandler_Expecter) GetChannelByID(responseWriter any, request any) *MockHandler_GetChannelByID_Call {
return &MockHandler_GetChannelByID_Call{Call: _e.mock.On("GetChannelByID", responseWriter, request)}
}
@@ -2638,7 +2736,7 @@ type MockHandler_GetNotificationChannel_Call struct {
// GetNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) GetNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_GetNotificationChannel_Call {
func (_e *MockHandler_Expecter) GetNotificationChannel(responseWriter any, request any) *MockHandler_GetNotificationChannel_Call {
return &MockHandler_GetNotificationChannel_Call{Call: _e.mock.On("GetNotificationChannel", responseWriter, request)}
}
@@ -2684,7 +2782,7 @@ type MockHandler_GetRoutePolicyByID_Call struct {
// GetRoutePolicyByID is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) GetRoutePolicyByID(responseWriter interface{}, request interface{}) *MockHandler_GetRoutePolicyByID_Call {
func (_e *MockHandler_Expecter) GetRoutePolicyByID(responseWriter any, request any) *MockHandler_GetRoutePolicyByID_Call {
return &MockHandler_GetRoutePolicyByID_Call{Call: _e.mock.On("GetRoutePolicyByID", responseWriter, request)}
}
@@ -2730,7 +2828,7 @@ type MockHandler_ListAllChannels_Call struct {
// ListAllChannels is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) ListAllChannels(responseWriter interface{}, request interface{}) *MockHandler_ListAllChannels_Call {
func (_e *MockHandler_Expecter) ListAllChannels(responseWriter any, request any) *MockHandler_ListAllChannels_Call {
return &MockHandler_ListAllChannels_Call{Call: _e.mock.On("ListAllChannels", responseWriter, request)}
}
@@ -2776,7 +2874,7 @@ type MockHandler_ListChannels_Call struct {
// ListChannels is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) ListChannels(responseWriter interface{}, request interface{}) *MockHandler_ListChannels_Call {
func (_e *MockHandler_Expecter) ListChannels(responseWriter any, request any) *MockHandler_ListChannels_Call {
return &MockHandler_ListChannels_Call{Call: _e.mock.On("ListChannels", responseWriter, request)}
}
@@ -2822,7 +2920,7 @@ type MockHandler_ListNotificationChannels_Call struct {
// ListNotificationChannels is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) ListNotificationChannels(responseWriter interface{}, request interface{}) *MockHandler_ListNotificationChannels_Call {
func (_e *MockHandler_Expecter) ListNotificationChannels(responseWriter any, request any) *MockHandler_ListNotificationChannels_Call {
return &MockHandler_ListNotificationChannels_Call{Call: _e.mock.On("ListNotificationChannels", responseWriter, request)}
}
@@ -2854,6 +2952,52 @@ func (_c *MockHandler_ListNotificationChannels_Call) RunAndReturn(run func(respo
return _c
}
// RepairNotificationChannel provides a mock function for the type MockHandler
func (_mock *MockHandler) RepairNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
return
}
// MockHandler_RepairNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RepairNotificationChannel'
type MockHandler_RepairNotificationChannel_Call struct {
*mock.Call
}
// RepairNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) RepairNotificationChannel(responseWriter any, request any) *MockHandler_RepairNotificationChannel_Call {
return &MockHandler_RepairNotificationChannel_Call{Call: _e.mock.On("RepairNotificationChannel", responseWriter, request)}
}
func (_c *MockHandler_RepairNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_RepairNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 http.ResponseWriter
if args[0] != nil {
arg0 = args[0].(http.ResponseWriter)
}
var arg1 *http.Request
if args[1] != nil {
arg1 = args[1].(*http.Request)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockHandler_RepairNotificationChannel_Call) Return() *MockHandler_RepairNotificationChannel_Call {
_c.Call.Return()
return _c
}
func (_c *MockHandler_RepairNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_RepairNotificationChannel_Call {
_c.Run(run)
return _c
}
// TestNotificationChannel provides a mock function for the type MockHandler
func (_mock *MockHandler) TestNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
@@ -2868,7 +3012,7 @@ type MockHandler_TestNotificationChannel_Call struct {
// TestNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) TestNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_TestNotificationChannel_Call {
func (_e *MockHandler_Expecter) TestNotificationChannel(responseWriter any, request any) *MockHandler_TestNotificationChannel_Call {
return &MockHandler_TestNotificationChannel_Call{Call: _e.mock.On("TestNotificationChannel", responseWriter, request)}
}
@@ -2914,7 +3058,7 @@ type MockHandler_TestReceiver_Call struct {
// TestReceiver is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) TestReceiver(responseWriter interface{}, request interface{}) *MockHandler_TestReceiver_Call {
func (_e *MockHandler_Expecter) TestReceiver(responseWriter any, request any) *MockHandler_TestReceiver_Call {
return &MockHandler_TestReceiver_Call{Call: _e.mock.On("TestReceiver", responseWriter, request)}
}
@@ -2960,7 +3104,7 @@ type MockHandler_UpdateChannelByID_Call struct {
// UpdateChannelByID is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) UpdateChannelByID(responseWriter interface{}, request interface{}) *MockHandler_UpdateChannelByID_Call {
func (_e *MockHandler_Expecter) UpdateChannelByID(responseWriter any, request any) *MockHandler_UpdateChannelByID_Call {
return &MockHandler_UpdateChannelByID_Call{Call: _e.mock.On("UpdateChannelByID", responseWriter, request)}
}
@@ -3006,7 +3150,7 @@ type MockHandler_UpdateNotificationChannel_Call struct {
// UpdateNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) UpdateNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_UpdateNotificationChannel_Call {
func (_e *MockHandler_Expecter) UpdateNotificationChannel(responseWriter any, request any) *MockHandler_UpdateNotificationChannel_Call {
return &MockHandler_UpdateNotificationChannel_Call{Call: _e.mock.On("UpdateNotificationChannel", responseWriter, request)}
}
@@ -3052,7 +3196,7 @@ type MockHandler_UpdateRoutePolicy_Call struct {
// UpdateRoutePolicy is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) UpdateRoutePolicy(responseWriter interface{}, request interface{}) *MockHandler_UpdateRoutePolicy_Call {
func (_e *MockHandler_Expecter) UpdateRoutePolicy(responseWriter any, request any) *MockHandler_UpdateRoutePolicy_Call {
return &MockHandler_UpdateRoutePolicy_Call{Call: _e.mock.On("UpdateRoutePolicy", responseWriter, request)}
}

View File

@@ -29,6 +29,8 @@ type Handler interface {
DeleteNotificationChannel(http.ResponseWriter, *http.Request)
RepairNotificationChannel(http.ResponseWriter, *http.Request)
TestNotificationChannel(http.ResponseWriter, *http.Request)
GetAllRoutePolicies(http.ResponseWriter, *http.Request)

View File

@@ -168,6 +168,37 @@ func (handler *handler) DeleteNotificationChannel(rw http.ResponseWriter, req *h
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) RepairNotificationChannel(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
params := new(alertmanagertypes.RepairChannelParams)
if err := binding.Query.BindQuery(req.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
repair, err := handler.alertmanager.RepairNotificationChannel(ctx, claims.OrgID, id, params.Apply)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, repair)
}
func (handler *handler) TestNotificationChannel(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -2,6 +2,7 @@ package signozalertmanager
import (
"context"
"fmt"
"time"
amConfig "github.com/prometheus/alertmanager/config"
@@ -343,6 +344,139 @@ func (provider *provider) TestNotificationChannel(ctx context.Context, orgID str
return provider.service.TestReceiver(ctx, orgID, receiver)
}
// RepairNotificationChannel refuses a delete while a route policy still names
// the channel, as DeleteChannelByID does. A split adds the new channels to
// every route policy naming the original, so what fanned out before still does.
func (provider *provider) RepairNotificationChannel(ctx context.Context, orgID string, id valuer.UUID, apply bool) (*alertmanagertypes.ChannelRepair, error) {
channel, err := provider.configStore.GetChannelByID(ctx, orgID, id)
if err != nil {
return nil, err
}
repair := channel.Diagnose()
switch repair.Action {
case alertmanagertypes.ChannelRepairActionRetype:
return provider.retypeChannel(ctx, orgID, channel, repair, apply)
case alertmanagertypes.ChannelRepairActionSplit:
return provider.splitChannel(ctx, orgID, channel, repair, apply)
case alertmanagertypes.ChannelRepairActionDelete:
return provider.deleteDefectiveChannel(ctx, orgID, channel, repair, apply)
}
repair.Channels = []*alertmanagertypes.ListedNotificationChannel{channel.ToListedNotificationChannel()}
return repair, nil
}
func (provider *provider) retypeChannel(ctx context.Context, orgID string, channel *alertmanagertypes.Channel, repair *alertmanagertypes.ChannelRepair, apply bool) (*alertmanagertypes.ChannelRepair, error) {
if err := channel.Retype(); err != nil {
return nil, err
}
repair.Channels = []*alertmanagertypes.ListedNotificationChannel{channel.ToListedNotificationChannel()}
if !apply {
return repair, nil
}
if err := provider.configStore.UpdateChannel(ctx, orgID, channel); err != nil {
return nil, err
}
repair.Applied = true
return repair, nil
}
func (provider *provider) splitChannel(ctx context.Context, orgID string, channel *alertmanagertypes.Channel, repair *alertmanagertypes.ChannelRepair, apply bool) (*alertmanagertypes.ChannelRepair, error) {
channels, err := channel.SplitByNotifier()
if err != nil {
return nil, err
}
for _, split := range channels {
repair.Channels = append(repair.Channels, split.ToListedNotificationChannel())
}
policies, err := provider.notificationManager.GetRoutePoliciesByChannel(ctx, orgID, channel.DisplayName)
if err != nil {
return nil, err
}
if !apply {
return repair, nil
}
config, err := provider.configStore.Get(ctx, orgID)
if err != nil {
return nil, err
}
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
return nil, err
}
receivers := make([]*alertmanagertypes.Receiver, 0, len(channels))
for _, split := range channels {
receiver, err := alertmanagertypes.NewReceiver(split.Data)
if err != nil {
return nil, err
}
receivers = append(receivers, receiver)
}
if err := config.UpdateReceiver(receivers[0]); err != nil {
return nil, err
}
for _, receiver := range receivers[1:] {
if err := config.CreateReceiverV2(receiver); err != nil {
return nil, err
}
}
for _, split := range channels[1:] {
if err := provider.configStore.CreateChannel(ctx, split); err != nil {
return nil, err
}
}
if err := provider.configStore.UpdateChannel(ctx, orgID, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
return provider.configStore.Set(ctx, config)
})); err != nil {
return nil, err
}
added := make([]string, 0, len(channels)-1)
for _, split := range channels[1:] {
added = append(added, split.DisplayName)
}
for _, policy := range policies {
postable := &alertmanagertypes.PostableRoutePolicy{
Expression: policy.Expression,
ExpressionKind: policy.ExpressionKind,
Channels: append(policy.Channels, added...),
Name: policy.Name,
Description: policy.Description,
Tags: policy.Tags,
}
if _, err := provider.UpdateRoutePolicyByID(ctx, policy.ID.String(), postable); err != nil {
return nil, err
}
}
repair.Applied = true
return repair, nil
}
func (provider *provider) deleteDefectiveChannel(ctx context.Context, orgID string, channel *alertmanagertypes.Channel, repair *alertmanagertypes.ChannelRepair, apply bool) (*alertmanagertypes.ChannelRepair, error) {
policies, err := provider.notificationManager.GetRoutePoliciesByChannel(ctx, orgID, channel.DisplayName)
if err != nil {
return nil, err
}
for _, policy := range policies {
repair.Blockers = append(repair.Blockers, fmt.Sprintf("used by routing policy %q", policy.Name))
}
if !apply {
return repair, nil
}
if err := provider.DeleteChannelByID(ctx, orgID, channel.ID); err != nil {
return nil, err
}
repair.Applied = true
return repair, nil
}
func (provider *provider) Config() alertmanagerserver.Config {
return provider.config.Signoz.Config
}

View File

@@ -266,6 +266,34 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/notification_channels/{id}/repair", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.RepairNotificationChannel, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "RepairNotificationChannel",
Tags: []string{"channels"},
Summary: "Repair notification channel",
Description: "This endpoint diagnoses a stored channel that the v2 API cannot read and applies the fitting action: a channel carrying several notifier configurations is split into one channel per configuration, keeping this ID for the first; a channel whose notifier kind v2 does not model is deleted; a channel with an empty stored type has it rewritten from its data. A delete is refused while a routing policy still names the channel. Nothing is written unless apply=true; by default the response only shows what would happen.",
Request: nil,
RequestQuery: new(alertmanagertypes.RepairChannelParams),
RequestContentType: "",
Response: new(alertmanagertypes.ChannelRepair),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceNotificationChannel,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/notification_channels/test", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.TestNotificationChannel, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{

35
pkg/cache/memorycache/budget_test.go vendored Normal file
View File

@@ -0,0 +1,35 @@
package memorycache
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func cachedDataOfSize(n int) *qbtypes.CachedData {
return &qbtypes.CachedData{Buckets: []*qbtypes.CachedBucket{{EndMs: 1, Type: qbtypes.RequestTypeTimeSeries, Value: make([]byte, n)}}}
}
// ristretto admits an update of an existing key without checking the budget,
// so an entry that grows in place could take the process past MaxCost.
func TestSet_GrowingEntryStaysWithinBudget(t *testing.T) {
const budget = 1 << 20
p, err := New(context.Background(), instrumentationtest.New().ToProviderSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{NumCounters: 1000, MaxCost: budget}})
require.NoError(t, err)
prov := p.(*provider)
orgID := valuer.GenerateUUID()
ctx := context.Background()
require.NoError(t, prov.Set(ctx, orgID, "entry", cachedDataOfSize(512<<10), time.Hour))
require.NoError(t, prov.Set(ctx, orgID, "entry", cachedDataOfSize(2<<20), time.Hour))
used := int64(prov.cc.Metrics.CostAdded()) - int64(prov.cc.Metrics.CostEvicted())
assert.LessOrEqual(t, used, int64(budget), "the cache holds %d bytes against a budget of %d", used, budget)
}

View File

@@ -120,7 +120,12 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
span.SetAttributes(attribute.Bool("memory.cloneable", true))
span.SetAttributes(attribute.Int64("memory.cost", cost))
toCache := cloneable.Clone()
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
// ristretto updates an existing key in place without admission, so an
// entry that grows would take the cache past MaxCost; delete first so
// the new cost is admitted like a new key.
key := strings.Join([]string{orgID.StringValue(), cacheKey}, "::")
provider.cc.Del(key)
if ok := provider.cc.SetWithTTL(key, toCache, cost, ttl); !ok {
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
}
@@ -137,7 +142,9 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
span.SetAttributes(attribute.Bool("memory.cloneable", false))
span.SetAttributes(attribute.Int64("memory.cost", cost))
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
key := strings.Join([]string{orgID.StringValue(), cacheKey}, "::")
provider.cc.Del(key)
if ok := provider.cc.SetWithTTL(key, toCache, cost, ttl); !ok {
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
}

View File

@@ -7,12 +7,10 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -26,11 +24,10 @@ const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
flagger flagger.Flagger
}
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, flagger: flagger, querier: querier}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, querier: querier}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -123,16 +120,6 @@ func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []byte, configVersion *opamptypes.AgentConfigVersion) ([]byte, string, error) {
ctx := context.Background()
// Skip the llm pricing processor unless AI observability is enabled for the org.
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
enabled, err := module.flagger.Boolean(ctx, flagger.FeatureEnableAIObservability, evalCtx)
if err != nil {
return nil, "", err
}
if !enabled {
return currentConfYaml, "", nil
}
rules, err := module.getEnabledRules(ctx, orgID)
if err != nil {
return nil, "", err

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -16,10 +17,11 @@ type setter struct {
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
dashboard dashboard.Module
spanMapper spanmapper.Module
}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module, spanMapper spanmapper.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard, spanMapper: spanMapper}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
@@ -43,6 +45,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := module.spanMapper.ReconcileSystemGroups(ctx, organization.ID); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,46 @@
package implspanmapper
import (
"embed"
"io/fs"
"path"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/spantypes"
)
const definitionsRoot = "fs/definitions"
//go:embed fs/definitions/*.json
var definitionFiles embed.FS
// NewSystemGroupRegistry parses every embedded definition. Definitions are
// build-time assets validated by a test, so a failure here means the binary
// shipped broken JSON.
func NewSystemGroupRegistry() (spantypes.SpanMapperGroupRegistry, error) {
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
if err != nil {
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read span mapper group definitions")
}
definitions := make([]spantypes.SpanMapperGroupDefinition, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
file := path.Join(definitionsRoot, entry.Name())
raw, err := definitionFiles.ReadFile(file)
if err != nil {
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
}
definition, err := spantypes.NewSpanMapperGroupDefinition(raw)
if err != nil {
return spantypes.SpanMapperGroupRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
}
definitions = append(definitions, definition)
}
return spantypes.NewSpanMapperGroupRegistry(definitions)
}

View File

@@ -0,0 +1,79 @@
{
"version": 1,
"definition": {
"name": "gen_ai.agent",
"condition": {
"attributes": [
{
"value": "agent"
}
],
"resource": []
},
"enabled": true,
"mappers": [
{
"name": "gen_ai.agent.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "agent.name",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "agent_name",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.agent.id",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "agent.id",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.agent.description",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "agent.description",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.output.messages",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "final_result",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
}
]
}
}

View File

@@ -0,0 +1,347 @@
{
"version": 1,
"definition": {
"name": "gen_ai.llm",
"condition": {
"attributes": [
{
"value": "model"
}
],
"resource": []
},
"enabled": true,
"mappers": [
{
"name": "gen_ai.request.model",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "llm.model_name",
"context": "attribute",
"operation": "copy",
"priority": 60
},
{
"key": "llm.request.model",
"context": "attribute",
"operation": "copy",
"priority": 50
},
{
"key": "ai.model.id",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "langfuse.observation.model.name",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "embedding.model_name",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "model",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.response.model",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "llm.response.model",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.response.model",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.provider.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.system",
"context": "attribute",
"operation": "copy",
"priority": 50
},
{
"key": "llm.vendor",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "llm.provider",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "llm.system",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.model.provider",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.operation.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "llm.request.type",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.usage.input_tokens",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.usage.prompt_tokens",
"context": "attribute",
"operation": "copy",
"priority": 50
},
{
"key": "llm.usage.prompt_tokens",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "llm.token_count.prompt",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "ai.usage.inputTokens",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.usage.promptTokens",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.usage.output_tokens",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.usage.completion_tokens",
"context": "attribute",
"operation": "copy",
"priority": 50
},
{
"key": "llm.usage.completion_tokens",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "llm.token_count.completion",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "ai.usage.outputTokens",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.usage.completionTokens",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.usage.cache_read.input_tokens",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.usage.cache_read_input_tokens",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "llm.token_count.prompt_details.cache_read",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.usage.cachedInputTokens",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.usage.cache_creation.input_tokens",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.usage.cache_write.input_tokens",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "gen_ai.usage.cache_creation_input_tokens",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "llm.token_count.prompt_details.cache_write",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.input.messages",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.prompt",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "ai.prompt.messages",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "input.value",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.output.messages",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "gen_ai.completion",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "ai.response.text",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "output.value",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.conversation.id",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "session.id",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "langfuse.session.id",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.response.finish_reason",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "ai.response.finishReason",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
}
]
}
}

View File

@@ -0,0 +1,135 @@
{
"version": 1,
"definition": {
"name": "gen_ai.tool",
"condition": {
"attributes": [
{
"value": "tool"
}
],
"resource": []
},
"enabled": true,
"mappers": [
{
"name": "gen_ai.tool.name",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "tool.name",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.toolCall.name",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.tool.call.id",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "tool.id",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "ai.toolCall.id",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.tool.description",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "tool.description",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.tool.call.arguments",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "ai.toolCall.args",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "traceloop.entity.input",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "gcp.vertex.agent.tool_call_args",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "input.value",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
},
{
"name": "gen_ai.tool.call.result",
"fieldContext": "attribute",
"config": {
"sources": [
{
"key": "ai.toolCall.result",
"context": "attribute",
"operation": "copy",
"priority": 40
},
{
"key": "traceloop.entity.output",
"context": "attribute",
"operation": "copy",
"priority": 30
},
{
"key": "gcp.vertex.agent.tool_response",
"context": "attribute",
"operation": "copy",
"priority": 20
},
{
"key": "output.value",
"context": "attribute",
"operation": "copy",
"priority": 10
}
]
}
}
]
}
}

View File

@@ -69,6 +69,10 @@ func (h *handler) CreateGroup(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, err)
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
group := spantypes.NewSpanMapperGroup(orgID, claims.Email, req)
@@ -191,6 +195,10 @@ func (h *handler) CreateMapper(rw http.ResponseWriter, r *http.Request) {
render.Error(rw, err)
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
mapper := spantypes.NewSpanMapper(groupID, claims.Email, req)
if err := h.module.CreateMapper(ctx, orgID, groupID, mapper); err != nil {

View File

@@ -5,22 +5,30 @@ import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// maxTestSpans bounds the input size: every test request boots a full
// in-memory collector pipeline and is reachable with viewer access.
const maxTestSpans = 100
type module struct {
store spantypes.SpanMapperStore
flagger flagger.Flagger
store spantypes.SpanMapperStore
registry spantypes.SpanMapperGroupRegistry
settings factory.ScopedProviderSettings
}
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger) spanmapper.Module {
return &module{store: store, flagger: flagger}
func NewModule(store spantypes.SpanMapperStore, registry spantypes.SpanMapperGroupRegistry, providerSettings factory.ProviderSettings) spanmapper.Module {
return &module{
store: store,
registry: registry,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
}
}
func (module *module) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
@@ -32,6 +40,9 @@ func (module *module) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spa
}
func (module *module) CreateGroup(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup) error {
if module.registry.IsReserved(group.Name) {
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", group.Name)
}
return module.store.CreateGroup(ctx, group)
}
@@ -40,10 +51,14 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
if err != nil {
return err
}
group.Update(name, condition, enabled, updatedBy)
if name != nil && *name != group.Name && module.registry.IsReserved(*name) {
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", *name)
}
if err := group.Update(name, condition, enabled, updatedBy); err != nil {
return err
}
err = module.store.UpdateGroup(ctx, group)
if err != nil {
if err := module.store.UpdateGroup(ctx, group); err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
@@ -51,8 +66,7 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
}
func (module *module) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
err := module.store.DeleteGroup(ctx, orgID, id)
if err != nil {
if err := module.store.DeleteGroup(ctx, orgID, id); err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
@@ -81,14 +95,13 @@ func (module *module) CreateMapper(ctx context.Context, orgID, groupID valuer.UU
}
func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error {
if _, err := module.store.GetGroup(ctx, orgID, groupID); err != nil {
return err
}
mapper, err := module.store.GetMapper(ctx, orgID, groupID, id)
if err != nil {
return err
}
mapper.Update(fieldContext, config, enabled, updatedBy)
if err := mapper.Update(fieldContext, config, enabled, updatedBy); err != nil {
return err
}
err = module.store.UpdateMapper(ctx, mapper)
if err != nil {
return err
@@ -98,18 +111,13 @@ func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id value
}
func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
err := module.store.DeleteMapper(ctx, orgID, groupID, id)
if err != nil {
if err := module.store.DeleteMapper(ctx, orgID, groupID, id, spantypes.SpanMapperOriginUser); err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
return nil
}
// maxTestSpans bounds the input size: every test request boots a full
// in-memory collector pipeline and is reachable with viewer access.
const maxTestSpans = 100
func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error) {
if len(spans) == 0 {
return nil, nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at least one span")
@@ -130,6 +138,31 @@ func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans
return out, collectorLogs, nil
}
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
return spantypes.SpanAttrMappingFeatureType
}
func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []byte, configVersion *opamptypes.AgentConfigVersion) ([]byte, string, error) {
ctx := context.Background()
enabledMappers, err := module.listEnabledGroupsWithMappers(ctx, orgID)
if err != nil {
return nil, "", err
}
updatedConf, err := spantypes.GenerateCollectorConfigWithSpanMapperProcessor(currentConfYaml, enabledMappers)
if err != nil {
return nil, "", err
}
serialized, err := json.Marshal(enabledMappers)
if err != nil {
return nil, "", err
}
return updatedConf, string(serialized), nil
}
// backfillMappers loads saved mappers for any enabled group whose Mappers is
// nil. Disabled groups are skipped: the simulation filters them out anyway,
// so there is no point loading their mappers or failing on their names.
@@ -161,41 +194,6 @@ func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, gr
return groups, nil
}
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
return spantypes.SpanAttrMappingFeatureType
}
func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []byte, configVersion *opamptypes.AgentConfigVersion) ([]byte, string, error) {
ctx := context.Background()
// Skip the llm pricing processor unless AI observability is enabled for the org.
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
enabled, err := module.flagger.Boolean(ctx, flagger.FeatureEnableAIObservability, evalCtx)
if err != nil {
return nil, "", err
}
if !enabled {
return currentConfYaml, "", nil
}
enabledMappers, err := module.listEnabledGroupsWithMappers(ctx, orgID)
if err != nil {
return nil, "", err
}
updatedConf, err := spantypes.GenerateCollectorConfigWithSpanMapperProcessor(currentConfYaml, enabledMappers)
if err != nil {
return nil, "", err
}
serialized, err := json.Marshal(enabled)
if err != nil {
return nil, "", err
}
return updatedConf, string(serialized), nil
}
// listEnabledGroupsWithMappers returns groups with their mappers.
func (module *module) listEnabledGroupsWithMappers(ctx context.Context, orgID valuer.UUID) ([]*spantypes.SpanMapperGroupWithMappers, error) {
enabled := true

View File

@@ -0,0 +1,228 @@
package implspanmapper
import (
"context"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testUser = "user@signoz.io"
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
for _, model := range []any{
(*spantypes.StorableSpanMapperGroup)(nil),
(*spantypes.StorableSpanMapper)(nil),
} {
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
require.NoError(t, err)
}
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_org_name ON span_mapper_group (org_id, name)`)
require.NoError(t, err)
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_name ON span_mapper (group_id, name)`)
require.NoError(t, err)
return store
}
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...spantypes.SpanMapperGroupDefinition) *module {
t.Helper()
registry, err := spantypes.NewSpanMapperGroupRegistry(definitions)
require.NoError(t, err)
return NewModule(NewStore(sqlStore), registry, factorytest.NewSettings()).(*module)
}
func newTestDefinition(t *testing.T, version int, body string) spantypes.SpanMapperGroupDefinition {
t.Helper()
definition, err := spantypes.NewSpanMapperGroupDefinition([]byte(`{"version": ` + strconv.Itoa(version) + `, "definition": ` + body + `}`))
require.NoError(t, err)
return definition
}
// llmV1 ships two mappers; llmV2 renames a source, adds a mapper and drops one.
const llmV1 = `{
"name": "llm",
"condition": {"attributes": [{"value": "model"}], "resource": []},
"enabled": true,
"mappers": [
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
{"key": "ai.model.id", "context": "attribute", "operation": "copy", "priority": 10}
]}},
{"name": "gen_ai.input.messages", "fieldContext": "attribute", "config": {"sources": [
{"key": "gen_ai.prompt", "context": "attribute", "operation": "copy", "priority": 10}
]}}
]
}`
const llmV2 = `{
"name": "llm",
"condition": {"attributes": [{"value": "model"}, {"value": "llm."}], "resource": []},
"enabled": true,
"mappers": [
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
{"key": "langfuse.observation.model.name", "context": "attribute", "operation": "copy", "priority": 10}
]}},
{"name": "gen_ai.provider.name", "fieldContext": "attribute", "config": {"sources": [
{"key": "llm.vendor", "context": "attribute", "operation": "copy", "priority": 10}
]}}
]
}`
func findMapper(t *testing.T, mappers []*spantypes.SpanMapper, name string) *spantypes.SpanMapper {
t.Helper()
for _, m := range mappers {
if m.Name == name {
return m
}
}
require.Failf(t, "mapper not found", "no mapper named %q", name)
return nil
}
func findSource(t *testing.T, sources []spantypes.SpanMapperSource, key string, origin spantypes.SpanMapperOrigin) spantypes.SpanMapperSource {
t.Helper()
for _, s := range sources {
if s.Key == key && s.Origin == origin {
return s
}
}
require.Failf(t, "source not found", "no %s source with key %q", origin.StringValue(), key)
return spantypes.SpanMapperSource{}
}
func TestReconcileUpgradeKeepsTogglesAndUserItems(t *testing.T) {
ctx := context.Background()
orgID := valuer.GenerateUUID()
sqlStore := newTestSQLStore(t)
v1 := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
require.NoError(t, v1.ReconcileSystemGroups(ctx, orgID))
group, err := v1.store.GetGroupByName(ctx, orgID, "llm")
require.NoError(t, err)
mappers, err := v1.ListMappers(ctx, orgID, group.ID)
require.NoError(t, err)
// Switch the shipped substring off and add a user one.
off := false
require.NoError(t, v1.UpdateGroup(ctx, orgID, group.ID, nil, &spantypes.SpanMapperGroupCondition{
Attributes: []spantypes.SpanMapperGroupConditionKey{
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
},
Resource: []spantypes.SpanMapperGroupConditionKey{},
}, &off, testUser))
// Switch a shipped source off, add a user override, and switch the mapper off.
model := findMapper(t, mappers, "gen_ai.request.model")
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, model.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 20, Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationMove, Priority: 1, Enabled: true, Origin: spantypes.SpanMapperOriginUser},
}}, &off, testUser))
// Add a user source to the mapper v2 stops shipping, so it must survive.
messages := findMapper(t, mappers, "gen_ai.input.messages")
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, messages.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
{Key: "input.value", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true},
}}, nil, testUser))
// A user mapper in the shipped group.
require.NoError(t, v1.CreateMapper(ctx, orgID, group.ID, spantypes.NewSpanMapper(group.ID, testUser, &spantypes.PostableSpanMapper{
Name: "gen_ai.custom", FieldContext: spantypes.FieldContextSpanAttribute, Enabled: true,
Config: spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{{Key: "custom", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true}}},
})))
v2 := newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2))
require.NoError(t, v2.ReconcileSystemGroups(ctx, orgID))
upgraded, err := v2.GetGroup(ctx, orgID, group.ID)
require.NoError(t, err)
assert.Equal(t, 2, upgraded.Version)
assert.False(t, upgraded.Enabled)
assert.Equal(t, spantypes.ProvisionerIdentity, upgraded.UpdatedBy)
assert.Equal(t, []spantypes.SpanMapperGroupConditionKey{
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
{Value: "llm.", Enabled: true, Origin: spantypes.SpanMapperOriginSystem},
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
}, upgraded.Condition.Attributes)
mappers, err = v2.ListMappers(ctx, orgID, group.ID)
require.NoError(t, err)
require.Len(t, mappers, 4)
model = findMapper(t, mappers, "gen_ai.request.model")
assert.False(t, model.Enabled)
assert.Equal(t, spantypes.SpanMapperOriginSystem, model.Origin)
assert.False(t, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginSystem).Enabled)
assert.True(t, findSource(t, model.Config.Sources, "langfuse.observation.model.name", spantypes.SpanMapperOriginSystem).Enabled)
assert.Equal(t, spantypes.SpanMapperOperationMove, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginUser).Operation)
assert.Len(t, model.Config.Sources, 3)
messages = findMapper(t, mappers, "gen_ai.input.messages")
assert.Equal(t, spantypes.SpanMapperOriginUser, messages.Origin)
require.Len(t, messages.Config.Sources, 1)
assert.Equal(t, "input.value", messages.Config.Sources[0].Key)
assert.Equal(t, spantypes.SpanMapperOriginSystem, findMapper(t, mappers, "gen_ai.provider.name").Origin)
assert.Equal(t, spantypes.SpanMapperOriginUser, findMapper(t, mappers, "gen_ai.custom").Origin)
// Shipping v1 again drops provider.name outright (no user sources) and
// re-adopts the surviving user mapper input.messages as a shipped one.
v3 := newTestModule(t, sqlStore, newTestDefinition(t, 3, llmV1))
require.NoError(t, v3.ReconcileSystemGroups(ctx, orgID))
mappers, err = v3.ListMappers(ctx, orgID, group.ID)
require.NoError(t, err)
require.Len(t, mappers, 3)
for _, m := range mappers {
assert.NotEqual(t, "gen_ai.provider.name", m.Name)
}
messages = findMapper(t, mappers, "gen_ai.input.messages")
assert.Equal(t, spantypes.SpanMapperOriginSystem, messages.Origin)
assert.Len(t, messages.Config.Sources, 2)
}
func TestReconcileDoesNotDowngrade(t *testing.T) {
ctx := context.Background()
orgID := valuer.GenerateUUID()
sqlStore := newTestSQLStore(t)
require.NoError(t, newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2)).ReconcileSystemGroups(ctx, orgID))
older := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
require.NoError(t, older.ReconcileSystemGroups(ctx, orgID))
group, err := older.store.GetGroupByName(ctx, orgID, "llm")
require.NoError(t, err)
assert.Equal(t, 2, group.Version)
mappers, err := older.ListMappers(ctx, orgID, group.ID)
require.NoError(t, err)
findMapper(t, mappers, "gen_ai.provider.name")
}

View File

@@ -0,0 +1,187 @@
package implspanmapper
import (
"context"
"log/slog"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (module *module) ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error {
for _, definition := range module.registry.List() {
if err := module.reconcileSystemGroup(ctx, orgID, definition); err != nil {
return err
}
}
agentConf.NotifyConfigUpdate(ctx)
return nil
}
// reconcileSystemGroup brings one org's copy of a definition to the shipped
// version in a single transaction. A concurrent provisioner (another replica,
// or the org-creation hook racing the startup sweep) loses on the group's
// unique (org_id, name) index and is treated as a no-op.
func (module *module) reconcileSystemGroup(ctx context.Context, orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) error {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
group, err := module.store.GetGroupByName(ctx, orgID, definition.Name())
if err != nil && errors.Ast(err, errors.TypeNotFound) {
group = newSystemGroup(orgID, definition)
err = module.store.CreateGroup(ctx, group)
}
if err != nil {
return err
}
if group.Origin != spantypes.SpanMapperOriginSystem {
module.settings.Logger().WarnContext(ctx, "skipping default span mapper group: a user group holds its name", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
return nil
}
if group.Version >= definition.Version {
return nil
}
return module.applyDefinition(ctx, orgID, group, definition)
})
if err != nil && errors.Ast(err, errors.TypeAlreadyExists) {
module.settings.Logger().DebugContext(ctx, "default span mapper group provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
return nil
}
return err
}
// applyDefinition replaces every shipped item with the definition, carrying each
// enabled flag over by identity, and leaves user items untouched. A mapper that
// is no longer shipped is deleted unless the user added sources to it, in which
// case it survives as a user mapper.
func (module *module) applyDefinition(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup, definition spantypes.SpanMapperGroupDefinition) error {
mappers, err := module.store.ListMappers(ctx, orgID, group.ID)
if err != nil {
return err
}
byName := make(map[string]*spantypes.SpanMapper, len(mappers))
for _, m := range mappers {
byName[m.Name] = m
}
now := time.Now()
for i := range definition.Definition.Mappers {
pm := &definition.Definition.Mappers[i]
mapper, exists := byName[pm.Name]
delete(byName, pm.Name)
if !exists {
if err := module.store.CreateMapper(ctx, newSystemMapper(group.ID, pm)); err != nil {
return err
}
continue
}
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, pm.Config.Sources)
mapper.FieldContext = pm.FieldContext
mapper.Origin = spantypes.SpanMapperOriginSystem
mapper.UpdatedAt = now
mapper.UpdatedBy = spantypes.ProvisionerIdentity
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
return err
}
}
// Whatever is left in byName is not shipped any more.
for _, mapper := range byName {
if mapper.Origin != spantypes.SpanMapperOriginSystem {
continue
}
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, nil)
if len(mapper.Config.Sources) == 0 {
if err := module.store.DeleteMapper(ctx, orgID, group.ID, mapper.ID, spantypes.SpanMapperOriginSystem); err != nil {
return err
}
continue
}
mapper.Origin = spantypes.SpanMapperOriginUser
mapper.UpdatedAt = now
mapper.UpdatedBy = spantypes.ProvisionerIdentity
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
return err
}
}
shipped := definition.Definition.Condition
group.Condition = spantypes.SpanMapperGroupCondition{
Attributes: mergeShippedConditionKeys(group.Condition.Attributes, shipped.Attributes),
Resource: mergeShippedConditionKeys(group.Condition.Resource, shipped.Resource),
}
group.Version = definition.Version
group.UpdatedAt = now
group.UpdatedBy = spantypes.ProvisionerIdentity
if err := module.store.UpdateGroup(ctx, group); err != nil {
return err
}
module.settings.Logger().InfoContext(ctx, "applied default span mapper group", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
// newSystemGroup is the empty shell applyDefinition fills: version 0 so the
// definition is applied right after the row exists.
func newSystemGroup(orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) *spantypes.SpanMapperGroup {
group := spantypes.NewSpanMapperGroup(orgID, spantypes.ProvisionerIdentity, &definition.Definition.PostableSpanMapperGroup)
group.Condition = definition.Definition.Condition
group.Enabled = true
group.Origin = spantypes.SpanMapperOriginSystem
return group
}
func newSystemMapper(groupID valuer.UUID, pm *spantypes.PostableSpanMapper) *spantypes.SpanMapper {
mapper := spantypes.NewSpanMapper(groupID, spantypes.ProvisionerIdentity, pm)
mapper.Config = pm.Config
mapper.Enabled = true
mapper.Origin = spantypes.SpanMapperOriginSystem
return mapper
}
// mergeShippedConditionKeys returns the shipped keys, each keeping the enabled
// flag of the stored system key with the same value, followed by the stored
// user keys.
func mergeShippedConditionKeys(stored, shipped []spantypes.SpanMapperGroupConditionKey) []spantypes.SpanMapperGroupConditionKey {
out := make([]spantypes.SpanMapperGroupConditionKey, 0, len(stored)+len(shipped))
for _, k := range shipped {
idx := slices.IndexFunc(stored, func(s spantypes.SpanMapperGroupConditionKey) bool {
return s.Origin == spantypes.SpanMapperOriginSystem && s.Value == k.Value
})
if idx != -1 {
k.Enabled = stored[idx].Enabled
}
out = append(out, k)
}
for _, k := range stored {
if k.Origin != spantypes.SpanMapperOriginSystem {
out = append(out, k)
}
}
return out
}
// mergeShippedSources returns the shipped sources, each keeping the enabled
// flag of the stored system source with the same key and context, followed by
// the stored user sources.
func mergeShippedSources(stored, shipped []spantypes.SpanMapperSource) []spantypes.SpanMapperSource {
out := make([]spantypes.SpanMapperSource, 0, len(stored)+len(shipped))
for _, s := range shipped {
idx := slices.IndexFunc(stored, func(o spantypes.SpanMapperSource) bool {
return o.Origin == spantypes.SpanMapperOriginSystem && o.Key == s.Key && o.Context == s.Context
})
if idx != -1 {
s.Enabled = stored[idx].Enabled
}
out = append(out, s)
}
for _, s := range stored {
if s.Origin != spantypes.SpanMapperOriginSystem {
out = append(out, s)
}
}
return out
}

View File

@@ -0,0 +1,81 @@
package implspanmapper
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
)
const reconcileRetryInterval = 30 * time.Second
type service struct {
settings factory.ScopedProviderSettings
module spanmapper.Module
orgGetter organization.Getter
stopC chan struct{}
healthyC chan struct{}
}
// NewService reconciles every org's default mapping groups once at startup.
// Orgs created later are reconciled by the organization setter instead.
func NewService(providerSettings factory.ProviderSettings, module spanmapper.Module, orgGetter organization.Getter) factory.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
module: module,
orgGetter: orgGetter,
stopC: make(chan struct{}),
healthyC: make(chan struct{}),
}
}
func (service *service) Start(ctx context.Context) error {
ticker := time.NewTicker(reconcileRetryInterval)
defer ticker.Stop()
for {
err := service.reconcile(ctx)
if err == nil {
close(service.healthyC)
<-service.stopC
return nil
}
service.settings.Logger().WarnContext(ctx, "default span mapper group reconciliation failed, retrying", errors.Attr(err))
select {
case <-service.stopC:
return nil
case <-ticker.C:
}
}
}
func (service *service) Healthy() <-chan struct{} {
return service.healthyC
}
func (service *service) Stop(_ context.Context) error {
close(service.stopC)
return nil
}
func (service *service) reconcile(ctx context.Context) error {
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return err
}
for _, org := range orgs {
if err := service.module.ReconcileSystemGroups(ctx, org.ID); err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile default span mapper groups for org %s", org.ID.StringValue())
}
}
service.settings.Logger().InfoContext(ctx, "default span mapper group reconciliation completed", slog.Int("orgs", len(orgs)))
return nil
}

View File

@@ -17,6 +17,10 @@ func NewStore(sqlstore sqlstore.SQLStore) spantypes.SpanMapperStore {
return &store{sqlstore: sqlstore}
}
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
}
func (s *store) CreateGroup(ctx context.Context, group *spantypes.SpanMapperGroup) error {
storable := group.ToStorable()
_, err := s.sqlstore.
@@ -34,7 +38,7 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
storable := new(spantypes.StorableSpanMapperGroup)
err := s.sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
@@ -46,11 +50,27 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
return storable.ToSpanMapperGroup(), nil
}
func (s *store) GetGroupByName(ctx context.Context, orgID valuer.UUID, name string) (*spantypes.SpanMapperGroup, error) {
storable := new(spantypes.StorableSpanMapperGroup)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, s.sqlstore.WrapNotFoundErrf(err, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %q not found", name)
}
return storable.ToSpanMapperGroup(), nil
}
func (s *store) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
storables := make([]*spantypes.StorableSpanMapperGroup, 0)
sel := s.sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(&storables).
Where("org_id = ?", orgID)
@@ -91,38 +111,43 @@ func (s *store) UpdateGroup(ctx context.Context, group *spantypes.SpanMapperGrou
}
func (s *store) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
tx, err := s.sqlstore.BunDBCtx(ctx).BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
return s.RunInTx(ctx, func(ctx context.Context) error {
db := s.sqlstore.BunDBCtx(ctx)
// Cascade: remove mappers belonging to this group first.
if _, err := tx.NewDelete().
Model((*spantypes.StorableSpanMapper)(nil)).
Where("group_id = ?", id).
Exec(ctx); err != nil {
return err
}
// Cascade: remove mappers belonging to this group first.
if _, err := db.NewDelete().
Model((*spantypes.StorableSpanMapper)(nil)).
Where("group_id = ?", id).
Exec(ctx); err != nil {
return err
}
res, err := tx.NewDelete().
Model((*spantypes.StorableSpanMapperGroup)(nil)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return err
}
res, err := db.NewDelete().
Model((*spantypes.StorableSpanMapperGroup)(nil)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Where("origin = ?", spantypes.SpanMapperOriginUser).
Exec(ctx)
if err != nil {
return err
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
}
return tx.Commit()
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
group, err := s.GetGroup(ctx, orgID, id)
if err != nil {
return err
}
if err := group.ErrIfNotDeletable(); err != nil {
return err
}
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
}
return nil
})
}
func (s *store) CreateMapper(ctx context.Context, mapper *spantypes.SpanMapper) error {
@@ -146,7 +171,7 @@ func (s *store) GetMapper(ctx context.Context, orgID, groupID, id valuer.UUID) (
storable := new(spantypes.StorableSpanMapper)
err := s.sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("group_id = ?", groupID).
@@ -166,7 +191,7 @@ func (s *store) ListMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]
storables := make([]*spantypes.StorableSpanMapper, 0)
if err := s.sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(&storables).
Where("group_id = ?", groupID).
@@ -200,7 +225,7 @@ func (s *store) UpdateMapper(ctx context.Context, mapper *spantypes.SpanMapper)
return nil
}
func (s *store) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
func (s *store) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID, origin spantypes.SpanMapperOrigin) error {
if _, err := s.GetGroup(ctx, orgID, groupID); err != nil {
return err
}
@@ -211,6 +236,7 @@ func (s *store) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID
Model((*spantypes.StorableSpanMapper)(nil)).
Where("group_id = ?", groupID).
Where("id = ?", id).
Where("origin = ?", origin).
Exec(ctx)
if err != nil {
return err
@@ -221,6 +247,13 @@ func (s *store) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID
return err
}
if rowsAffected == 0 {
mapper, err := s.GetMapper(ctx, orgID, groupID, id)
if err != nil {
return err
}
if err := mapper.ErrIfNotDeletable(); err != nil {
return err
}
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMapperNotFound, "span mapper %s not found", id)
}
return nil

View File

@@ -28,6 +28,10 @@ type Module interface {
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error)
// ReconcileSystemGroups provisions or upgrades the shipped mapping groups
// for one org. It runs at startup for every org and again on org creation.
ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error
}
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/errors"
@@ -63,6 +64,11 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Error(rw, err)
return
}
// The standard way for a client to ask for a fresh answer; the body flag
// stays for callers that build the request themselves.
if strings.Contains(strings.ToLower(req.Header.Get("Cache-Control")), "no-cache") {
queryRangeRequest.NoCache = true
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {

File diff suppressed because it is too large Load Diff

View File

@@ -16,59 +16,38 @@ import (
"github.com/stretchr/testify/require"
)
// BenchmarkBucketCache_GetMissRanges benchmarks the GetMissRanges operation.
const benchStepMs = uint64(1000)
func benchStep() qbtypes.Step { return qbtypes.Step{Duration: time.Second} }
func benchRequest(fingerprint string, startMs, endMs uint64) CacheRequest {
return CacheRequest{Key: CacheKey(fingerprint), Window: qbtypes.TimeRange{From: startMs, To: endMs}, Step: benchStep(), Kind: qbtypes.RequestTypeTimeSeries}
}
func BenchmarkBucketCache_GetMissRanges(b *testing.B) {
bc := createBenchmarkBucketCache(b)
ctx := context.Background()
orgID := valuer.UUID{}
// Pre-populate cache with some data
for i := 0; i < 10; i++ {
query := &mockQuery{
fingerprint: fmt.Sprintf("bench-query-%d", i),
startMs: uint64(i * 10000),
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
req := benchRequest(fmt.Sprintf("bench-query-%d", i), uint64(i*10000), uint64((i+1)*10000))
bc.Put(ctx, orgID, req, req.Window, createBenchmarkResult(req.Window.From, req.Window.To))
}
// Create test queries with varying cache hit patterns
queries := []struct {
name string
query *mockQuery
requests := []struct {
name string
req CacheRequest
}{
{
name: "full_cache_hit",
query: &mockQuery{
fingerprint: "bench-query-5",
startMs: 50000,
endMs: 60000,
},
},
{
name: "full_cache_miss",
query: &mockQuery{
fingerprint: "bench-query-new",
startMs: 100000,
endMs: 110000,
},
},
{
name: "partial_cache_hit",
query: &mockQuery{
fingerprint: "bench-query-5",
startMs: 45000,
endMs: 65000,
},
},
{name: "full_cache_hit", req: benchRequest("bench-query-5", 50000, 60000)},
{name: "full_cache_miss", req: benchRequest("bench-query-new", 100000, 110000)},
{name: "partial_cache_hit", req: benchRequest("bench-query-5", 45000, 65000)},
}
for _, tc := range queries {
for _, tc := range requests {
b.Run(tc.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
cached, missing := bc.GetMissRanges(ctx, orgID, tc.query, qbtypes.Step{Duration: 1000 * time.Millisecond})
cached, missing := bc.GetMissRanges(ctx, orgID, tc.req)
_ = cached
_ = missing
}
@@ -76,222 +55,165 @@ func BenchmarkBucketCache_GetMissRanges(b *testing.B) {
}
}
// BenchmarkBucketCache_Put benchmarks the Put operation.
func BenchmarkBucketCache_Put(b *testing.B) {
bc := createBenchmarkBucketCache(b)
ctx := context.Background()
orgID := valuer.UUID{}
testCases := []struct {
name string
numSeries int
numValues int
numQueries int
name string
numSeries int
numValues int
}{
{"small_result_1_series_100_values", 1, 100, 1},
{"medium_result_10_series_100_values", 10, 100, 1},
{"large_result_100_series_100_values", 100, 100, 1},
{"huge_result_1000_series_100_values", 1000, 100, 1},
{"many_values_10_series_1000_values", 10, 1000, 1},
{"small_result_1_series_100_values", 1, 100},
{"medium_result_10_series_100_values", 10, 100},
{"large_result_100_series_100_values", 100, 100},
{"huge_result_1000_series_100_values", 1000, 100},
{"many_values_10_series_1000_values", 10, 1000},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
// Create test data
queries := make([]*mockQuery, tc.numQueries)
results := make([]*qbtypes.Result, tc.numQueries)
for i := 0; i < tc.numQueries; i++ {
queries[i] = &mockQuery{
fingerprint: fmt.Sprintf("bench-put-query-%d", i),
startMs: uint64(i * 100000),
endMs: uint64((i + 1) * 100000),
}
results[i] = createBenchmarkResultWithSeries(
queries[i].startMs,
queries[i].endMs,
1000,
tc.numSeries,
tc.numValues,
)
}
req := benchRequest("bench-put-"+tc.name, 0, uint64(tc.numValues)*benchStepMs)
result := createBenchmarkResultWithSeries(req.Window.From, req.Window.To, tc.numSeries, tc.numValues)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for j := 0; j < tc.numQueries; j++ {
bc.Put(ctx, orgID, queries[j], qbtypes.Step{Duration: 1000 * time.Millisecond}, results[j])
}
bc.Put(ctx, orgID, req, req.Window, result)
}
})
}
}
// BenchmarkBucketCache_MergeTimeSeriesValues benchmarks merging of time series data.
func BenchmarkBucketCache_MergeTimeSeriesValues(b *testing.B) {
bc := createBenchmarkBucketCache(b).(*bucketCache)
// BenchmarkBucketCache_SlidingRefresh is the dashboard pattern: every refresh
// moves the window one step and writes the new step back.
func BenchmarkBucketCache_SlidingRefresh(b *testing.B) {
bc := createBenchmarkBucketCache(b)
ctx := context.Background()
orgID := valuer.UUID{}
window := uint64(3600) * benchStepMs
testCases := []struct {
name string
numBuckets int
numSeries int
numValues int
}{
{"small_2_buckets_10_series", 2, 10, 100},
{"medium_5_buckets_50_series", 5, 50, 100},
{"large_10_buckets_100_series", 10, 100, 100},
{"many_buckets_20_buckets_50_series", 20, 50, 100},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
// Create test buckets
buckets := make([]*qbtypes.CachedBucket, tc.numBuckets)
for i := 0; i < tc.numBuckets; i++ {
startMs := uint64(i * 10000)
endMs := uint64((i + 1) * 10000)
result := createBenchmarkResultWithSeries(startMs, endMs, 1000, tc.numSeries, tc.numValues)
valueBytes, _ := json.Marshal(result.Value)
buckets[i] = &qbtypes.CachedBucket{
StartMs: startMs,
EndMs: endMs,
Type: qbtypes.RequestTypeTimeSeries,
Value: valueBytes,
Stats: result.Stats,
}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
result := bc.mergeTimeSeriesValues(context.Background(), buckets)
_ = result
}
})
b.ReportAllocs()
for i := 0; i < b.N; i++ {
start := uint64(i) * benchStepMs
req := benchRequest("bench-sliding", start, start+window)
cached, missing := bc.GetMissRanges(ctx, orgID, req)
_ = cached
for _, gap := range missing {
bc.Put(ctx, orgID, req, gap, createBenchmarkResult(gap.From, gap.To))
}
}
}
// BenchmarkBucketCache_FindMissingRangesWithStep benchmarks finding missing ranges.
func BenchmarkBucketCache_FindMissingRangesWithStep(b *testing.B) {
bc := createBenchmarkBucketCache(b).(*bucketCache)
testCases := []struct {
name string
numBuckets int
gapPattern string // "none", "uniform", "random"
}{
{"no_gaps_10_buckets", 10, "none"},
{"uniform_gaps_10_buckets", 10, "uniform"},
{"random_gaps_20_buckets", 20, "random"},
{"many_buckets_100", 100, "uniform"},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
// Create test buckets based on pattern
buckets := createBucketsWithPattern(tc.numBuckets, tc.gapPattern)
startMs := uint64(0)
endMs := uint64(tc.numBuckets * 20000)
stepMs := uint64(1000)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
missing := bc.findMissingRangesWithStep(buckets, startMs, endMs, stepMs)
_ = missing
}
})
}
}
// BenchmarkGetUniqueSeriesKey benchmarks the series key generation.
func BenchmarkGetUniqueSeriesKey(b *testing.B) {
func BenchmarkMergeTimeSeriesData(b *testing.B) {
testCases := []struct {
name string
numLabels int
numParts int
numSeries int
numValues int
}{
{"1_label", 1},
{"5_labels", 5},
{"10_labels", 10},
{"20_labels", 20},
{"50_labels", 50},
{"small_2_parts_10_series", 2, 10, 100},
{"medium_5_parts_50_series", 5, 50, 100},
{"large_10_parts_100_series", 10, 100, 100},
{"many_parts_20_parts_50_series", 20, 50, 100},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
labels := make([]*qbtypes.Label, tc.numLabels)
for i := 0; i < tc.numLabels; i++ {
parts := make([]*qbtypes.TimeSeriesData, tc.numParts)
for i := range parts {
startMs := uint64(i) * uint64(tc.numValues) * benchStepMs
parts[i] = createBenchmarkResultWithSeries(startMs, startMs+uint64(tc.numValues)*benchStepMs, tc.numSeries, tc.numValues).Value.(*qbtypes.TimeSeriesData)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = mergeTimeSeriesData(parts)
}
})
}
}
func BenchmarkBucketCache_Decode(b *testing.B) {
bc := createBenchmarkBucketCache(b).(*bucketCache)
testCases := []struct {
name string
numBuckets int
numSeries int
}{
{"1_bucket_10_series", 1, 10},
{"5_buckets_50_series", 5, 50},
{"20_buckets_100_series", 20, 100},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
buckets := make([]*qbtypes.CachedBucket, tc.numBuckets)
for i := range buckets {
startMs := uint64(i * 10000)
result := createBenchmarkResultWithSeries(startMs, startMs+10000, tc.numSeries, 10)
value, err := json.Marshal(result.Value)
require.NoError(b, err)
buckets[i] = &qbtypes.CachedBucket{StartMs: startMs, EndMs: startMs + 10000, WrittenAtMs: time.Now().UnixMilli(), Type: qbtypes.RequestTypeTimeSeries, Value: value}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = bc.decode(context.Background(), buckets, qbtypes.TimeRange{To: ^uint64(0)})
}
})
}
}
func BenchmarkGetUniqueSeriesKey(b *testing.B) {
for _, numLabels := range []int{1, 5, 10, 20, 50} {
b.Run(fmt.Sprintf("%d_labels", numLabels), func(b *testing.B) {
labels := make([]*qbtypes.Label, numLabels)
for i := range labels {
labels[i] = &qbtypes.Label{
Key: telemetrytypes.TelemetryFieldKey{
Name: fmt.Sprintf("label_%d", i),
FieldDataType: telemetrytypes.FieldDataTypeString,
},
Key: telemetrytypes.TelemetryFieldKey{Name: fmt.Sprintf("label_%d", i), FieldDataType: telemetrytypes.FieldDataTypeString},
Value: fmt.Sprintf("value_%d", i),
}
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
key := qbtypes.GetUniqueSeriesKey(labels)
_ = key
_ = qbtypes.GetUniqueSeriesKey(labels)
}
})
}
}
// BenchmarkBucketCache_ConcurrentOperations benchmarks concurrent cache operations.
func BenchmarkBucketCache_ConcurrentOperations(b *testing.B) {
bc := createBenchmarkBucketCache(b)
ctx := context.Background()
orgID := valuer.UUID{}
// Pre-populate cache
for i := 0; i < 100; i++ {
query := &mockQuery{
fingerprint: fmt.Sprintf("concurrent-query-%d", i),
startMs: uint64(i * 10000),
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
req := benchRequest(fmt.Sprintf("concurrent-query-%d", i), uint64(i*10000), uint64((i+1)*10000))
bc.Put(ctx, orgID, req, req.Window, createBenchmarkResult(req.Window.From, req.Window.To))
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
// Mix of operations
switch i % 3 {
case 0: // Read
query := &mockQuery{
fingerprint: fmt.Sprintf("concurrent-query-%d", i%100),
startMs: uint64((i % 100) * 10000),
endMs: uint64(((i % 100) + 1) * 10000),
}
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
case 0:
req := benchRequest(fmt.Sprintf("concurrent-query-%d", i%100), uint64((i%100)*10000), uint64(((i%100)+1)*10000))
cached, missing := bc.GetMissRanges(ctx, orgID, req)
_ = cached
_ = missing
case 1: // Write
query := &mockQuery{
fingerprint: fmt.Sprintf("concurrent-query-new-%d", i),
startMs: uint64(i * 10000),
endMs: uint64((i + 1) * 10000),
}
result := createBenchmarkResult(query.startMs, query.endMs, 1000)
bc.Put(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond}, result)
case 2: // Partial read
query := &mockQuery{
fingerprint: fmt.Sprintf("concurrent-query-%d", i%100),
startMs: uint64((i%100)*10000 - 5000),
endMs: uint64(((i%100)+1)*10000 + 5000),
}
cached, missing := bc.GetMissRanges(ctx, orgID, query, qbtypes.Step{Duration: 1000 * time.Millisecond})
case 1:
req := benchRequest(fmt.Sprintf("concurrent-query-new-%d", i), uint64(i*10000), uint64((i+1)*10000))
bc.Put(ctx, orgID, req, req.Window, createBenchmarkResult(req.Window.From, req.Window.To))
case 2:
req := benchRequest(fmt.Sprintf("concurrent-query-%d", i%100), uint64((i%100)*10000+5000), uint64(((i%100)+1)*10000+5000))
cached, missing := bc.GetMissRanges(ctx, orgID, req)
_ = cached
_ = missing
}
@@ -300,41 +222,6 @@ func BenchmarkBucketCache_ConcurrentOperations(b *testing.B) {
})
}
// BenchmarkBucketCache_FilterResultToTimeRange benchmarks filtering results to time range.
func BenchmarkBucketCache_FilterResultToTimeRange(b *testing.B) {
bc := createBenchmarkBucketCache(b).(*bucketCache)
testCases := []struct {
name string
numSeries int
numValues int
}{
{"small_10_series_100_values", 10, 100},
{"medium_50_series_500_values", 50, 500},
{"large_100_series_1000_values", 100, 1000},
}
for _, tc := range testCases {
b.Run(tc.name, func(b *testing.B) {
// Create a large result
result := createBenchmarkResultWithSeries(0, 100000, 1000, tc.numSeries, tc.numValues)
// Filter to middle 50%
startMs := uint64(25000)
endMs := uint64(75000)
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
filtered := bc.filterResultToTimeRange(result, startMs, endMs)
_ = filtered
}
})
}
}
// Helper function to create benchmark bucket cache.
func createBenchmarkBucketCache(tb testing.TB) BucketCache {
config := cache.Config{
Provider: "memory",
@@ -348,65 +235,40 @@ func createBenchmarkBucketCache(tb testing.TB) BucketCache {
return NewBucketCache(instrumentationtest.New().ToProviderSettings(), memCache, time.Hour, 5*time.Minute)
}
// Helper function to create benchmark result.
func createBenchmarkResult(startMs, endMs uint64, step uint64) *qbtypes.Result {
return createBenchmarkResultWithSeries(startMs, endMs, step, 10, 100)
func createBenchmarkResult(startMs, endMs uint64) *qbtypes.Result {
return createBenchmarkResultWithSeries(startMs, endMs, 10, int((endMs-startMs)/benchStepMs))
}
// Helper function to create benchmark result with specific series and values.
func createBenchmarkResultWithSeries(startMs, endMs uint64, _ uint64, numSeries, numValuesPerSeries int) *qbtypes.Result {
// createBenchmarkResultWithSeries spreads numValuesPerSeries points over
// [startMs, endMs) on the step grid.
func createBenchmarkResultWithSeries(startMs, endMs uint64, numSeries, numValuesPerSeries int) *qbtypes.Result {
series := make([]*qbtypes.TimeSeries, numSeries)
valueStep := max((endMs-startMs)/uint64(max(numValuesPerSeries, 1)), benchStepMs)
valueStep -= valueStep % benchStepMs
for i := 0; i < numSeries; i++ {
ts := &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "host",
FieldDataType: telemetrytypes.FieldDataTypeString,
},
Value: fmt.Sprintf("server-%d", i),
},
{
Key: telemetrytypes.TelemetryFieldKey{
Name: "service",
FieldDataType: telemetrytypes.FieldDataTypeString,
},
Value: fmt.Sprintf("service-%d", i%5),
},
{Key: telemetrytypes.TelemetryFieldKey{Name: "host", FieldDataType: telemetrytypes.FieldDataTypeString}, Value: fmt.Sprintf("server-%d", i)},
{Key: telemetrytypes.TelemetryFieldKey{Name: "service", FieldDataType: telemetrytypes.FieldDataTypeString}, Value: fmt.Sprintf("service-%d", i%5)},
},
Values: make([]*qbtypes.TimeSeriesValue, 0, numValuesPerSeries),
}
// Generate values
valueStep := (endMs - startMs) / uint64(numValuesPerSeries)
if valueStep == 0 {
valueStep = 1
}
for j := 0; j < numValuesPerSeries; j++ {
timestamp := int64(startMs + uint64(j)*valueStep)
if timestamp < int64(endMs) {
ts.Values = append(ts.Values, &qbtypes.TimeSeriesValue{
Timestamp: timestamp,
Value: float64(i*100 + j),
})
timestamp := startMs + uint64(j)*valueStep
if timestamp+benchStepMs > endMs {
break
}
ts.Values = append(ts.Values, &qbtypes.TimeSeriesValue{Timestamp: int64(timestamp), Value: float64(i*100 + j)})
}
series[i] = ts
}
return &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: &qbtypes.TimeSeriesData{
QueryName: "benchmark_query",
Aggregations: []*qbtypes.AggregationBucket{
{
Index: 0,
Series: series,
},
},
QueryName: "benchmark_query",
Aggregations: []*qbtypes.AggregationBucket{{Index: 0, Series: series}},
},
Stats: qbtypes.ExecStats{
RowsScanned: uint64(numSeries * numValuesPerSeries),
@@ -415,31 +277,3 @@ func createBenchmarkResultWithSeries(startMs, endMs uint64, _ uint64, numSeries,
},
}
}
// Helper function to create buckets with specific gap patterns.
func createBucketsWithPattern(numBuckets int, pattern string) []*qbtypes.CachedBucket {
buckets := make([]*qbtypes.CachedBucket, 0, numBuckets)
for i := 0; i < numBuckets; i++ {
// Skip some buckets based on pattern
if pattern == "uniform" && i%3 == 0 {
continue // Create gaps every 3rd bucket
}
if pattern == "random" && i%7 < 2 {
continue // Create random gaps
}
startMs := uint64(i * 10000)
endMs := uint64((i + 1) * 10000)
buckets = append(buckets, &qbtypes.CachedBucket{
StartMs: startMs,
EndMs: endMs,
Type: qbtypes.RequestTypeTimeSeries,
Value: json.RawMessage(`{}`),
Stats: qbtypes.ExecStats{},
})
}
return buckets
}

View File

@@ -0,0 +1,93 @@
package querier
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
// The cache keeps points at full precision. The response encoder of
// TimeSeriesValue rounds values for readers, and a rounded value fed back
// into post-processing gives another answer than the uncached query.
type cachedPoint struct {
Timestamp int64 `json:"t"`
Value float64 `json:"v"`
Values []float64 `json:"vs,omitempty"`
Partial bool `json:"p,omitempty"`
}
type cachedSeries struct {
Labels []*qbtypes.Label `json:"labels,omitempty"`
Points []*cachedPoint `json:"points"`
}
type cachedAggregation struct {
Index int `json:"index"`
Alias string `json:"alias,omitempty"`
Meta qbtypes.AggregationMeta `json:"meta,omitempty"`
Series []*cachedSeries `json:"series"`
}
type cachedValue struct {
QueryName string `json:"queryName,omitempty"`
Aggregations []*cachedAggregation `json:"aggregations"`
}
func encodeBucketValue(data *qbtypes.TimeSeriesData) ([]byte, error) {
value := cachedValue{QueryName: data.QueryName, Aggregations: make([]*cachedAggregation, 0, len(data.Aggregations))}
for _, agg := range data.Aggregations {
if agg == nil {
continue
}
encoded := &cachedAggregation{Index: agg.Index, Alias: agg.Alias, Meta: agg.Meta, Series: make([]*cachedSeries, 0, len(agg.Series))}
for _, s := range agg.Series {
if s == nil {
continue
}
series := &cachedSeries{Labels: s.Labels, Points: make([]*cachedPoint, 0, len(s.Values))}
for _, v := range s.Values {
if v == nil {
continue
}
series.Points = append(series.Points, &cachedPoint{Timestamp: v.Timestamp, Value: v.Value, Values: v.Values, Partial: v.Partial})
}
encoded.Series = append(encoded.Series, series)
}
value.Aggregations = append(value.Aggregations, encoded)
}
return json.Marshal(value)
}
// decodeBucketValue rejects a payload with null elements: a bucket is either
// whole or not usable, since a reader cannot tell a dropped point from an
// absent one.
func decodeBucketValue(raw []byte) (*qbtypes.TimeSeriesData, error) {
var value cachedValue
if err := json.Unmarshal(raw, &value); err != nil {
return nil, err
}
data := &qbtypes.TimeSeriesData{QueryName: value.QueryName, Aggregations: make([]*qbtypes.AggregationBucket, 0, len(value.Aggregations))}
for _, agg := range value.Aggregations {
if agg == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "cached bucket has a null aggregation")
}
decoded := &qbtypes.AggregationBucket{Index: agg.Index, Alias: agg.Alias, Meta: agg.Meta, Series: make([]*qbtypes.TimeSeries, 0, len(agg.Series))}
for _, s := range agg.Series {
if s == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "cached bucket has a null series")
}
series := &qbtypes.TimeSeries{Labels: s.Labels, Values: make([]*qbtypes.TimeSeriesValue, 0, len(s.Points))}
for _, p := range s.Points {
if p == nil {
return nil, errors.NewInternalf(errors.CodeInternal, "cached bucket has a null point")
}
series.Values = append(series.Values, &qbtypes.TimeSeriesValue{Timestamp: p.Timestamp, Value: p.Value, Values: p.Values, Partial: p.Partial})
}
decoded.Series = append(decoded.Series, series)
}
data.Aggregations = append(data.Aggregations, decoded)
}
return data, nil
}

View File

@@ -1,117 +0,0 @@
package querier
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBucketCacheStepAlignment(t *testing.T) {
ctx := context.Background()
orgID := valuer.UUID{}
cache := createTestCache(t)
bc := NewBucketCache(instrumentationtest.New().ToProviderSettings(), cache, time.Hour, 5*time.Minute)
// Test with 5-minute step
step := qbtypes.Step{Duration: 5 * time.Minute}
// Query from 12:02 to 12:58 (both unaligned)
// Complete intervals: 12:05 to 12:55
query := &mockQuery{
fingerprint: "test-step-alignment",
startMs: 1672563720000, // 12:02
endMs: 1672567080000, // 12:58
}
result := &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: &qbtypes.TimeSeriesData{
QueryName: "test",
Aggregations: []*qbtypes.AggregationBucket{
{
Index: 0,
Series: []*qbtypes.TimeSeries{
{
Labels: []*qbtypes.Label{
{Key: telemetrytypes.TelemetryFieldKey{Name: "service"}, Value: "test"},
},
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1672563720000, Value: 1, Partial: true}, // 12:02
{Timestamp: 1672563900000, Value: 2}, // 12:05
{Timestamp: 1672564200000, Value: 2.5}, // 12:10
{Timestamp: 1672564500000, Value: 2.6}, // 12:15
{Timestamp: 1672566600000, Value: 2.9}, // 12:50
{Timestamp: 1672566900000, Value: 3}, // 12:55
{Timestamp: 1672567080000, Value: 4, Partial: true}, // 12:58
},
},
},
},
},
},
}
// Put result in cache
bc.Put(ctx, orgID, query, step, result)
// Get cached data
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
// Should have cached data
require.NotNil(t, cached)
// Log the missing ranges to debug
t.Logf("Missing ranges: %v", missing)
for i, r := range missing {
t.Logf("Missing range %d: From=%d, To=%d", i, r.From, r.To)
}
// Should have 2 missing ranges for partial intervals
require.Len(t, missing, 2)
// First partial: 12:02 to 12:05
assert.Equal(t, uint64(1672563720000), missing[0].From)
assert.Equal(t, uint64(1672563900000), missing[0].To)
// Second partial: 12:55 to 12:58
assert.Equal(t, uint64(1672566900000), missing[1].From, "Second missing range From")
assert.Equal(t, uint64(1672567080000), missing[1].To, "Second missing range To")
}
func TestBucketCacheNoStepInterval(t *testing.T) {
ctx := context.Background()
orgID := valuer.UUID{}
cache := createTestCache(t)
bc := NewBucketCache(instrumentationtest.New().ToProviderSettings(), cache, time.Hour, 5*time.Minute)
// Test with no step (stepMs = 0)
step := qbtypes.Step{Duration: 0}
query := &mockQuery{
fingerprint: "test-no-step",
startMs: 1672563720000,
endMs: 1672567080000,
}
result := &qbtypes.Result{
Type: qbtypes.RequestTypeTimeSeries,
Value: &qbtypes.TimeSeriesData{
QueryName: "test",
Aggregations: []*qbtypes.AggregationBucket{{Index: 0, Series: []*qbtypes.TimeSeries{}}},
},
}
// Should cache the entire range when step is 0
bc.Put(ctx, orgID, query, step, result)
cached, missing := bc.GetMissRanges(ctx, orgID, query, step)
assert.NotNil(t, cached)
assert.Len(t, missing, 0)
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,10 @@ package querier
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"sort"
"strconv"
"strings"
"time"
@@ -147,11 +149,22 @@ func (q *builderQuery[T]) Fingerprint() string {
if q.spec.Filter != nil && q.spec.Filter.Expression != "" {
parts = append(parts, fmt.Sprintf("filter=%s", q.spec.Filter.Expression))
for name, item := range q.variables {
// Sorted so the key is the same on every call, and JSON so that
// ["a b"] and ["a", "b"], or 1 and "1", get different keys.
names := make([]string, 0, len(q.variables))
for name := range q.variables {
if strings.Contains(q.spec.Filter.Expression, "$"+name) {
parts = append(parts, fmt.Sprintf("%s=%s", name, fmt.Sprint(item.Value)))
names = append(names, name)
}
}
sort.Strings(names)
for _, name := range names {
value, err := json.Marshal(q.variables[name].Value)
if err != nil {
value = []byte(fmt.Sprint(q.variables[name].Value))
}
parts = append(parts, fmt.Sprintf("%s=%s", name, value))
}
}
// Add group by keys
@@ -189,9 +202,37 @@ func (q *builderQuery[T]) Fingerprint() string {
parts = append(parts, fmt.Sprintf("shiftby=%d", q.spec.ShiftBy))
}
// A top-N is ranked over the statement window, so its result serves only
// the identical window.
if q.wholeWindowOnly() {
parts = append(parts, fmt.Sprintf("window=%d-%d", q.fromMS, q.toMS))
}
return strings.Join(parts, "&")
}
// wholeWindowOnly reports whether the statement ranks or limits groups over
// its window (the top-N CTE of logs and traces), which pieces of the window
// cannot reproduce. Metrics apply their limit after the statement.
func (q *builderQuery[T]) wholeWindowOnly() bool {
if q.spec.Limit <= 0 || len(q.spec.GroupBy) == 0 {
return false
}
return q.spec.Signal == telemetrytypes.SignalLogs || q.spec.Signal == telemetrytypes.SignalTraces
}
// lookbackSteps is how many steps before the window the result must carry.
// runningDiff drops its first point, so the metrics builder fetches one step
// before the window to give the first interval a difference.
func (q *builderQuery[T]) lookbackSteps() int {
for _, fn := range q.spec.Functions {
if fn.Name == qbtypes.FunctionNameRunningDiff {
return 1
}
}
return 0
}
// fingerprintHeatmapBucketing captures only what changes the rows ClickHouse
// returns, which is why LogBucketsSpec.Scale is absent: coarsening it happens in
// postprocessing, so every scale reads one cache entry.

View File

@@ -0,0 +1,658 @@
package querier
import (
"context"
"fmt"
"math/rand"
"sort"
"strings"
"testing"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/DATA-DOG/go-sqlmock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// Differential check of the bucket cache: random request sequences over a
// random dataset are answered twice, through the cache and with NoCache, and
// the two answers must be identical. ClickHouse is replaced by an in-process
// fake that evaluates the statement the fake builders render (window, step,
// limit) against the dataset with the semantics of the real statements:
// rows filtered to [start, end), bucketed to the step grid, grouped by
// service, the top-N of a limited query chosen over the statement window,
// and rate computed against the previous bucket within the lookback.
// consume, executeWithCache, the bucket cache and post-processing are the
// real code. Mismatches are reported by request shape and symptom.
const (
fuzzDatasetStartMs = epochMs
fuzzDatasetMinutes = 6 * 60
fuzzServices = 6
)
// fuzzDataset holds, per service and per minute, how many log rows exist (one
// second apart from the minute start) and the gauge value reported at the
// minute start.
type fuzzDataset struct {
counts [fuzzServices][fuzzDatasetMinutes]int
gauges [fuzzServices][fuzzDatasetMinutes]float64
// counters are cumulative samples at the minute start; a negative entry
// means no sample that minute (a gap), which is where window functions
// see a different predecessor depending on the statement window.
counters [fuzzServices][fuzzDatasetMinutes]float64
}
func newFuzzDataset(rng *rand.Rand) *fuzzDataset {
d := &fuzzDataset{}
for s := 0; s < fuzzServices; s++ {
// Every service is active for one or two stretches so that windows
// exist where a service has no rows at all.
activeFrom := rng.Intn(fuzzDatasetMinutes / 2)
activeTo := activeFrom + 30 + rng.Intn(fuzzDatasetMinutes/2)
for m := 0; m < fuzzDatasetMinutes; m++ {
if m >= activeFrom && m < activeTo {
d.counts[s][m] = rng.Intn(6)
}
d.gauges[s][m] = float64(100 + 10*s + rng.Intn(50))
}
gapFrom := rng.Intn(fuzzDatasetMinutes - 20)
gapTo := gapFrom + 3 + rng.Intn(12)
total := float64(1000 * (s + 1))
for m := 0; m < fuzzDatasetMinutes; m++ {
total += float64(rng.Intn(20))
if (m >= gapFrom && m < gapTo) || rng.Intn(25) == 0 {
d.counters[s][m] = -1
continue
}
d.counters[s][m] = total
}
}
return d
}
func fuzzServiceName(s int) string { return fmt.Sprintf("svc-%c", 'a'+s) }
// logRows evaluates the logs time series statement: count of rows in
// [startMs, endMs) per (bucket, service); with limit > 0 only the limit
// services with the highest count over the window are kept, ties broken by
// name as ClickHouse would break them deterministically for one plan.
func (d *fuzzDataset) logRows(startMs, endMs, stepMs uint64, limit int) [][]any {
type key struct {
ts uint64
service int
}
perBucket := map[key]float64{}
total := make([]float64, fuzzServices)
for s := 0; s < fuzzServices; s++ {
for m := 0; m < fuzzDatasetMinutes; m++ {
minuteMs := fuzzDatasetStartMs + uint64(m)*60_000
for i := 0; i < d.counts[s][m]; i++ {
ts := minuteMs + uint64(i+1)*1000
if ts < startMs || ts >= endMs {
continue
}
perBucket[key{ts - ts%stepMs, s}]++
total[s]++
}
}
}
keep := map[int]bool{}
if limit > 0 {
order := make([]int, 0, fuzzServices)
for s := 0; s < fuzzServices; s++ {
if total[s] > 0 {
order = append(order, s)
}
}
sort.Slice(order, func(i, j int) bool {
if total[order[i]] != total[order[j]] {
return total[order[i]] > total[order[j]]
}
return order[i] < order[j]
})
for i, s := range order {
if i < limit {
keep[s] = true
}
}
}
var rows [][]any
for k, count := range perBucket {
if limit > 0 && !keep[k.service] {
continue
}
rows = append(rows, []any{time.UnixMilli(int64(k.ts)), fuzzServiceName(k.service), count})
}
sort.Slice(rows, func(i, j int) bool {
ti, tj := rows[i][0].(time.Time), rows[j][0].(time.Time)
if !ti.Equal(tj) {
return ti.Before(tj)
}
return rows[i][1].(string) < rows[j][1].(string)
})
return rows
}
// gaugeRows evaluates the metrics statement for avg over the gauge: one row
// per (bucket, service) with the mean of the samples in [startMs, endMs).
func (d *fuzzDataset) gaugeRows(startMs, endMs, stepMs uint64) [][]any {
type key struct {
ts uint64
service int
}
sum := map[key]float64{}
n := map[key]float64{}
for s := 0; s < fuzzServices; s++ {
for m := 0; m < fuzzDatasetMinutes; m++ {
ts := fuzzDatasetStartMs + uint64(m)*60_000
if ts < startMs || ts >= endMs {
continue
}
k := key{ts - ts%stepMs, s}
sum[k] += d.gauges[s][m]
n[k]++
}
}
var rows [][]any
for k := range sum {
rows = append(rows, []any{time.UnixMilli(int64(k.ts)), fuzzServiceName(k.service), sum[k] / n[k]})
}
sort.Slice(rows, func(i, j int) bool {
ti, tj := rows[i][0].(time.Time), rows[j][0].(time.Time)
if !ti.Equal(tj) {
return ti.Before(tj)
}
return rows[i][1].(string) < rows[j][1].(string)
})
return rows
}
// rateRows evaluates the metrics statement for rate over the cumulative
// counter: per (bucket, service) the last sample of the bucket, then for
// each bucket the difference to the previous present bucket divided by the
// seconds between them (resets fall back to value / dt). A bucket without a
// predecessor within the lookback is nan, which consume drops, and the
// lookback buckets before the window are not part of the answer.
func (d *fuzzDataset) rateRows(startMs, endMs, stepMs uint64) [][]any {
lookbackMs := querybuilder.RateLookbackMs(stepMs)
var rows [][]any
for s := 0; s < fuzzServices; s++ {
type bucket struct {
ts uint64
value float64
}
var buckets []bucket
for m := 0; m < fuzzDatasetMinutes; m++ {
ts := fuzzDatasetStartMs + uint64(m)*60_000
if ts < startMs || ts >= endMs || d.counters[s][m] < 0 {
continue
}
b := ts - ts%stepMs
if len(buckets) > 0 && buckets[len(buckets)-1].ts == b {
buckets[len(buckets)-1].value = d.counters[s][m]
continue
}
buckets = append(buckets, bucket{ts: b, value: d.counters[s][m]})
}
for i := 1; i < len(buckets); i++ {
if buckets[i].ts-buckets[i-1].ts > lookbackMs || buckets[i].ts < startMs+lookbackMs {
continue
}
dt := float64(buckets[i].ts-buckets[i-1].ts) / 1000
rate := (buckets[i].value - buckets[i-1].value) / dt
if buckets[i].value < buckets[i-1].value {
rate = buckets[i].value / dt
}
rows = append(rows, []any{time.UnixMilli(int64(buckets[i].ts)), fuzzServiceName(s), rate})
}
}
sort.Slice(rows, func(i, j int) bool {
ti, tj := rows[i][0].(time.Time), rows[j][0].(time.Time)
if !ti.Equal(tj) {
return ti.Before(tj)
}
return rows[i][1].(string) < rows[j][1].(string)
})
return rows
}
// fuzzConn answers the statements the fuzz builders render from the dataset.
type fuzzConn struct {
clickhouse.Conn
data *fuzzDataset
}
func (c *fuzzConn) Query(_ context.Context, query string, _ ...any) (driver.Rows, error) {
var kind string
var start, end, step uint64
var limit int
if _, err := fmt.Sscanf(query, "FUZZ %s %d %d %d %d", &kind, &start, &end, &step, &limit); err != nil {
return nil, fmt.Errorf("fuzz conn: cannot parse %q: %w", query, err)
}
switch kind {
case "logs":
return cmock.NewRows(windowColumns, c.data.logRows(start, end, step, limit)), nil
case "gauge":
return cmock.NewRows(windowColumns, c.data.gaugeRows(start, end, step)), nil
case "rate":
return cmock.NewRows(windowColumns, c.data.rateRows(start, end, step)), nil
}
return nil, fmt.Errorf("fuzz conn: unknown kind %q", kind)
}
type fuzzStore struct {
*telemetrystoretest.Provider
conn clickhouse.Conn
}
func (s *fuzzStore) ClickhouseDB() clickhouse.Conn { return s.conn }
type fuzzLogStmtBuilder struct{}
func (fuzzLogStmtBuilder) Build(_ context.Context, _ valuer.UUID, start, end uint64, _ qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], _ map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
return &qbtypes.Statement{Query: fmt.Sprintf("FUZZ logs %d %d %d %d", start, end, uint64(query.StepInterval.Milliseconds()), query.Limit)}, nil
}
type fuzzMetricStmtBuilder struct{}
func (fuzzMetricStmtBuilder) Build(_ context.Context, _ valuer.UUID, start, end uint64, _ qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], _ map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
kind := "gauge"
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
kind = "rate"
}
return &qbtypes.Statement{Query: fmt.Sprintf("FUZZ %s %d %d %d 0", kind, start, end, uint64(query.StepInterval.Milliseconds()))}, nil
}
// fuzzShape is one query shape a session keeps for all its requests.
type fuzzShape struct {
metrics bool
rate bool
stepMs uint64
limit int
shiftSec int64
runningDiff bool
}
func (s fuzzShape) String() string {
parts := []string{fmt.Sprintf("step=%ds", s.stepMs/1000)}
if s.rate {
parts = append(parts, "metrics/rate")
} else if s.metrics {
parts = append(parts, "metrics/avg")
} else {
parts = append(parts, "logs/count")
}
if s.limit > 0 {
parts = append(parts, fmt.Sprintf("limit=%d", s.limit))
}
if s.shiftSec > 0 {
parts = append(parts, fmt.Sprintf("timeShift=%d", s.shiftSec))
}
if s.runningDiff {
parts = append(parts, "runningDiff")
}
return strings.Join(parts, " ")
}
func (s fuzzShape) envelope() (qbtypes.QueryEnvelope, qbtypes.Step) {
step := qbtypes.Step{Duration: time.Duration(s.stepMs) * time.Millisecond}
var functions []qbtypes.Function
if s.shiftSec > 0 {
functions = append(functions, qbtypes.Function{Name: qbtypes.FunctionNameTimeShift, Args: []qbtypes.FunctionArg{{Value: float64(s.shiftSec)}}})
}
if s.runningDiff {
functions = append(functions, qbtypes.Function{Name: qbtypes.FunctionNameRunningDiff})
}
groupBy := []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name", FieldDataType: telemetrytypes.FieldDataTypeString, FieldContext: telemetrytypes.FieldContextResource}}}
if s.rate {
return qbtypes.QueryEnvelope{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A", Signal: telemetrytypes.SignalMetrics, StepInterval: step, GroupBy: groupBy, Functions: functions,
Aggregations: []qbtypes.MetricAggregation{{MetricName: "fuzz_counter", Type: metrictypes.SumType, Temporality: metrictypes.Cumulative, TimeAggregation: metrictypes.TimeAggregationRate, SpaceAggregation: metrictypes.SpaceAggregationSum}},
}}, step
}
if s.metrics {
return qbtypes.QueryEnvelope{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A", Signal: telemetrytypes.SignalMetrics, StepInterval: step, GroupBy: groupBy, Functions: functions,
Aggregations: []qbtypes.MetricAggregation{{MetricName: "fuzz_gauge", Type: metrictypes.GaugeType, TimeAggregation: metrictypes.TimeAggregationAvg, SpaceAggregation: metrictypes.SpaceAggregationAvg}},
}}, step
}
spec := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "A", Signal: telemetrytypes.SignalLogs, StepInterval: step, GroupBy: groupBy, Functions: functions,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
}
if s.limit > 0 {
spec.Limit = s.limit
spec.Order = []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "count()"}}, Direction: qbtypes.OrderDirectionDesc}}
}
return qbtypes.QueryEnvelope{Type: qbtypes.QueryTypeBuilder, Spec: spec}, step
}
// fuzzPoint is the comparable projection of one response value.
type fuzzPoint struct {
ts int64
value float64
partial bool
}
type fuzzResponse map[string][]fuzzPoint
func runFuzzRequest(t *testing.T, q *querier, orgID valuer.UUID, shape fuzzShape, window qbtypes.TimeRange, noCache bool) fuzzResponse {
t.Helper()
envelope, step := shape.envelope()
req := &qbtypes.QueryRangeRequest{Start: window.From, End: window.To, RequestType: qbtypes.RequestTypeTimeSeries, NoCache: noCache, CompositeQuery: qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{envelope}}}
// The same steps QueryRange takes before run: shift extraction and window adjustment.
var query qbtypes.Query
switch spec := envelope.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
query = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.logStmtBuilder, qbtypes.QueryTypeBuilder, spec, adjustTimeRangeForShift(spec, window, req.RequestType), req.RequestType, nil, builderConfig{})
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
query = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, qbtypes.QueryTypeBuilder, spec, adjustTimeRangeForShift(spec, window, req.RequestType), req.RequestType, nil, builderConfig{})
}
resp, err := q.run(context.Background(), orgID, map[string]qbtypes.Query{"A": query}, req, map[string]qbtypes.Step{"A": step}, &qbtypes.QBEvent{}, nil)
require.NoError(t, err)
out := fuzzResponse{}
for _, result := range resp.Data.Results {
tsData, ok := result.(*qbtypes.TimeSeriesData)
if !ok {
continue
}
for _, agg := range tsData.Aggregations {
for _, s := range agg.Series {
name := ""
if len(s.Labels) > 0 {
name = fmt.Sprint(s.Labels[0].Value)
}
points := make([]fuzzPoint, 0, len(s.Values))
for _, v := range s.Values {
points = append(points, fuzzPoint{ts: v.Timestamp, value: v.Value, partial: v.Partial})
}
sort.Slice(points, func(i, j int) bool { return points[i].ts < points[j].ts })
out[name] = points
}
}
}
return out
}
// fuzzWindow draws a request window. Ends are on the step grid half of the
// time and a random number of seconds off it otherwise, like dashboards.
func fuzzWindow(rng *rand.Rand, stepMs uint64, previous *qbtypes.TimeRange, alignedOnly bool) qbtypes.TimeRange {
datasetEnd := fuzzDatasetStartMs + uint64(fuzzDatasetMinutes)*60_000
lengths := []uint64{30_000, 3 * 60_000, 17 * 60_000, 60 * 60_000, 2 * 60 * 60_000}
length := lengths[rng.Intn(len(lengths))]
var start uint64
if previous != nil && rng.Intn(3) > 0 {
// Related to the previous window: slide, grow, shrink, or nest.
delta := int64(rng.Intn(31)-15) * 60_000
start = uint64(int64(previous.From) + delta)
if rng.Intn(2) == 0 {
length = previous.To - previous.From
}
} else {
start = fuzzDatasetStartMs + uint64(rng.Intn(fuzzDatasetMinutes-10))*60_000
}
if start < fuzzDatasetStartMs {
start = fuzzDatasetStartMs
}
if !alignedOnly && rng.Intn(2) == 0 {
start += uint64(rng.Intn(60)) * 1000
}
end := start + length
if !alignedOnly && rng.Intn(2) == 0 {
end += uint64(rng.Intn(60)) * 1000
}
if alignedOnly {
start -= start % stepMs
end -= end % stepMs
}
if end > datasetEnd {
end = datasetEnd
}
if end <= start {
end = start + stepMs
}
return qbtypes.TimeRange{From: start, To: end}
}
type fuzzMismatch struct {
shape fuzzShape
window qbtypes.TimeRange
relation string
symptom string
detail string
}
func describeWindow(w qbtypes.TimeRange, stepMs uint64, history []qbtypes.TimeRange) string {
var parts []string
if w.From%stepMs != 0 {
parts = append(parts, "start-unaligned")
}
if w.To%stepMs != 0 {
parts = append(parts, "end-unaligned")
}
if w.To-w.From < stepMs {
parts = append(parts, "sub-step")
}
relation := "first"
if len(history) > 0 {
relation = "disjoint"
for _, h := range history {
switch {
case h.From == w.From && h.To == w.To:
relation = "repeat"
case w.From >= h.From && w.To <= h.To:
relation = "inside-cached"
case w.From <= h.From && w.To >= h.To:
relation = "covers-cached"
case w.From < h.To && w.To > h.From:
relation = "overlaps-cached"
}
if relation != "disjoint" {
break
}
}
}
parts = append(parts, relation)
return strings.Join(parts, ",")
}
func compareFuzz(cached, fresh fuzzResponse) (symptom, detail string) {
var symptoms []string
var details []string
for name := range fresh {
if _, ok := cached[name]; !ok {
symptoms = append(symptoms, "series-missing")
details = append(details, fmt.Sprintf("%s missing", name))
}
}
for name := range cached {
if _, ok := fresh[name]; !ok {
symptoms = append(symptoms, "series-extra")
details = append(details, fmt.Sprintf("%s extra (%d points)", name, len(cached[name])))
}
}
names := make([]string, 0, len(fresh))
for name := range fresh {
if _, ok := cached[name]; ok {
names = append(names, name)
}
}
sort.Strings(names)
for _, name := range names {
want, got := fresh[name], cached[name]
wantByTs := map[int64]fuzzPoint{}
for _, p := range want {
wantByTs[p.ts] = p
}
gotByTs := map[int64]fuzzPoint{}
for _, p := range got {
gotByTs[p.ts] = p
}
var tss []int64
for ts := range wantByTs {
tss = append(tss, ts)
}
for ts := range gotByTs {
if _, ok := wantByTs[ts]; !ok {
tss = append(tss, ts)
}
}
sort.Slice(tss, func(i, j int) bool { return tss[i] < tss[j] })
for _, ts := range tss {
g, gok := gotByTs[ts]
w, wok := wantByTs[ts]
switch {
case !gok:
symptoms = append(symptoms, "points-missing")
details = append(details, fmt.Sprintf("%s@%s missing (fresh %g%s)", name, fuzzClock(ts), w.value, fuzzFlag(w.partial)))
case !wok:
symptoms = append(symptoms, "points-extra")
details = append(details, fmt.Sprintf("%s@%s extra (cached %g%s)", name, fuzzClock(ts), g.value, fuzzFlag(g.partial)))
case g.value != w.value:
symptoms = append(symptoms, "value-differs")
details = append(details, fmt.Sprintf("%s@%s cached %g%s fresh %g%s", name, fuzzClock(ts), g.value, fuzzFlag(g.partial), w.value, fuzzFlag(w.partial)))
case g.partial != w.partial:
symptoms = append(symptoms, "partial-flag-differs")
details = append(details, fmt.Sprintf("%s@%s cached %g%s fresh %g%s", name, fuzzClock(ts), g.value, fuzzFlag(g.partial), w.value, fuzzFlag(w.partial)))
}
}
}
sort.Strings(symptoms)
symptoms = uniqueStrings(symptoms)
if len(details) > 6 {
details = append(details[:6], fmt.Sprintf("... %d more", len(details)-6))
}
return strings.Join(symptoms, "+"), strings.Join(details, "; ")
}
func fuzzClock(ms int64) string {
return time.UnixMilli(ms).UTC().Format("15:04:05")
}
func fuzzFlag(partial bool) string {
if partial {
return "(partial)"
}
return ""
}
func uniqueStrings(in []string) []string {
out := in[:0]
for i, s := range in {
if i == 0 || s != in[i-1] {
out = append(out, s)
}
}
return out
}
// TestCacheDifferential_CachedMatchesUncached runs random request sequences
// and requires every cached answer to equal the uncached one. The report
// groups mismatches by shape, window relation and symptom.
func TestCacheDifferential_CachedMatchesUncached(t *testing.T) {
for _, seed := range []int64{20260910, 1, 2, 3} {
t.Run(fmt.Sprintf("seed_%d", seed), func(t *testing.T) { runCacheDifferential(t, seed, 600, false) })
}
}
// TestCacheDifferential_AlignedWindowsMatchUncached keeps every window on the
// step grid, the shape an aligned client sends.
func TestCacheDifferential_AlignedWindowsMatchUncached(t *testing.T) {
for _, seed := range []int64{20260910, 1} {
t.Run(fmt.Sprintf("seed_%d", seed), func(t *testing.T) { runCacheDifferential(t, seed, 600, true) })
}
}
func runCacheDifferential(t *testing.T, seed int64, sessions int, alignedOnly bool) {
rng := rand.New(rand.NewSource(seed))
data := newFuzzDataset(rng)
store := &fuzzStore{Provider: telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp), conn: &fuzzConn{data: data}}
var mismatches []fuzzMismatch
requests := 0
for session := 0; session < sessions; session++ {
shape := fuzzShape{stepMs: []uint64{60_000, 300_000}[rng.Intn(2)]}
switch rng.Intn(7) {
case 0:
shape.limit = 1 + rng.Intn(2)
case 1:
shape.shiftSec = 3600
case 2:
shape.metrics = true
case 3:
shape.metrics = true
shape.runningDiff = true
case 4:
shape.metrics = true
shape.rate = true
}
q := &querier{
logger: instrumentationtest.New().Logger(),
fl: flaggertest.New(t),
telemetryStore: store,
logStmtBuilder: fuzzLogStmtBuilder{},
metricStmtBuilder: fuzzMetricStmtBuilder{},
bucketCache: createTestBucketCache(t),
maxConcurrentQueries: DefaultMaxConcurrentQueries,
}
orgID := valuer.GenerateUUID()
var history []qbtypes.TimeRange
steps := 2 + rng.Intn(5)
for i := 0; i < steps; i++ {
var previous *qbtypes.TimeRange
if len(history) > 0 {
previous = &history[len(history)-1]
}
window := fuzzWindow(rng, shape.stepMs, previous, alignedOnly)
cached := runFuzzRequest(t, q, orgID, shape, window, false)
fresh := runFuzzRequest(t, q, orgID, shape, window, true)
requests++
if symptom, detail := compareFuzz(cached, fresh); symptom != "" {
mismatches = append(mismatches, fuzzMismatch{shape: shape, window: window, relation: describeWindow(window, shape.stepMs, history), symptom: symptom, detail: detail})
}
history = append(history, window)
}
}
if len(mismatches) == 0 {
return
}
type class struct{ shape, geometry, symptom string }
counts := map[class]int{}
example := map[class]fuzzMismatch{}
for _, m := range mismatches {
c := class{m.shape.String(), m.relation, m.symptom}
counts[c]++
if _, ok := example[c]; !ok {
example[c] = m
}
}
classes := make([]class, 0, len(counts))
for c := range counts {
classes = append(classes, c)
}
sort.Slice(classes, func(i, j int) bool { return counts[classes[i]] > counts[classes[j]] })
var report strings.Builder
fmt.Fprintf(&report, "%d of %d requests differ from the uncached answer (seed %d); %d classes\n", len(mismatches), requests, seed, len(classes))
for _, c := range classes {
e := example[c]
fmt.Fprintf(&report, " %4d [%s] %s -> %s\n e.g. %s-%s: %s\n", counts[c], c.shape, c.geometry, c.symptom, fuzzClock(int64(e.window.From)), fuzzClock(int64(e.window.To)), e.detail)
}
t.Fatal(report.String())
}

View File

@@ -3,14 +3,13 @@ package querier
import (
"testing"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeTimeSeriesResultsUnionsHeatmapAxes(t *testing.T) {
func TestMergeTimeSeriesDataUnionsHeatmapAxes(t *testing.T) {
// a log axis holds whichever bands the data reached, so a wide cached range
// and a narrow fresh one routinely disagree on which bands exist
cached := &qbtypes.TimeSeriesData{
@@ -24,21 +23,19 @@ func TestMergeTimeSeriesResultsUnionsHeatmapAxes(t *testing.T) {
}},
}},
}
fresh := []*qbtypes.Result{{
Value: &qbtypes.TimeSeriesData{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
Index: 0,
Meta: qbtypes.AggregationMeta{Buckets: []float64{2, 4}},
Series: []*qbtypes.TimeSeries{{
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host.name"}, Value: "node-1"}},
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000060000, Values: []float64{5, 6, 7}}},
}},
fresh := &qbtypes.TimeSeriesData{
QueryName: "A",
Aggregations: []*qbtypes.AggregationBucket{{
Index: 0,
Meta: qbtypes.AggregationMeta{Buckets: []float64{2, 4}},
Series: []*qbtypes.TimeSeries{{
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "host.name"}, Value: "node-1"}},
Values: []*qbtypes.TimeSeriesValue{{Timestamp: 1710000060000, Values: []float64{5, 6, 7}}},
}},
},
}}
}},
}
merged := (&querier{}).mergeTimeSeriesResults(cached, fresh)
merged := mergeTimeSeriesData([]*qbtypes.TimeSeriesData{cached, fresh})
require.Len(t, merged.Aggregations, 1)
aggBucket := merged.Aggregations[0]
@@ -50,40 +47,10 @@ func TestMergeTimeSeriesResultsUnionsHeatmapAxes(t *testing.T) {
assert.Equal(t, []float64{1, 0, 2, 3, 4}, aggBucket.Series[0].Values[0].Values)
// and the fresh 2 band survives even though the cached range never had it
assert.Equal(t, []float64{0, 5, 6, 0, 7}, aggBucket.Series[0].Values[1].Values)
}
func TestTrimResultToFluxBoundaryKeepsTheHeatmapAxis(t *testing.T) {
cache := &bucketCache{logger: instrumentationtest.New().Logger()}
result := &qbtypes.Result{
Type: qbtypes.RequestTypeHeatmap,
Value: &qbtypes.TimeSeriesData{
Aggregations: []*qbtypes.AggregationBucket{{
Index: 0,
Alias: "__result_0",
Meta: qbtypes.AggregationMeta{Unit: "By", Buckets: []float64{1, 2, 4}},
Series: []*qbtypes.TimeSeries{{
Values: []*qbtypes.TimeSeriesValue{
{Timestamp: 1710000000000, Values: []float64{1, 2, 3, 4}},
},
}},
}},
},
}
trimmed := cache.trimResultToFluxBoundary(result, 1710000060000)
tsData, ok := trimmed.Value.(*qbtypes.TimeSeriesData)
require.True(t, ok)
require.Len(t, tsData.Aggregations, 1)
// the counts are positional against the axis, so a cached bucket that lost
// Meta.Buckets would be realigned from an empty axis and collapse into the
// overflow slot on the way back out
aggBucket := tsData.Aggregations[0]
assert.Equal(t, []float64{1, 2, 4}, aggBucket.Meta.Buckets)
assert.Equal(t, "By", aggBucket.Meta.Unit)
assert.Equal(t, "__result_0", aggBucket.Alias)
// the parts are left as they were, since the fresh one is written to the cache afterwards
assert.Equal(t, []float64{2, 4}, fresh.Aggregations[0].Meta.Buckets)
assert.Equal(t, []float64{5, 6, 7}, fresh.Aggregations[0].Series[0].Values[0].Values)
}
func TestRealignFromAnEmptyAxisCollapsesIntoTheOverflow(t *testing.T) {

View File

@@ -22,7 +22,7 @@ import (
const promHistogramBucketLabel = "le"
// cumulativeColumn maps a bucket's upper bound to the cumulative count at it.
// Differencing turns it into the per-band counts a heatmapColumn holds.
// Differencing turns it into the per-bucket counts a heatmapColumn holds.
type cumulativeColumn map[float64]float64
// promHeatmapGroup assembles one group across the several matrix series its `le`
@@ -34,8 +34,8 @@ type promHeatmapGroup struct {
}
// foldMatrixAsHeatmap folds a matrix of one cumulative series per (group, `le`)
// into one series per group whose points hold a count per band.
func foldMatrixAsHeatmap(matrix promql.Matrix, queryWindow *qbv5.TimeRange, stepMs uint64, queryName string) (*qbv5.TimeSeriesData, error) {
// into one series per group whose points hold a count per bucket.
func foldMatrixAsHeatmap(matrix promql.Matrix, queryName string) (*qbv5.TimeSeriesData, error) {
groups, groupOrder := collectCumulativeGroups(matrix)
// An empty matrix is only ever the window having no data, but series that
@@ -53,11 +53,12 @@ func foldMatrixAsHeatmap(matrix promql.Matrix, queryWindow *qbv5.TimeRange, step
}
}
return accumulator.foldSeries(queryWindow, stepMs, queryName)
// a promql data point can never be partial, hence nil and 0 are sent here
return accumulator.foldSeries(nil, 0, queryName)
}
// collectCumulativeGroups reads the matrix into one group per label set. A series
// without `le` has no band to sit in, so an expression that dropped the label
// without `le` has no bucket to sit in, so an expression that dropped the label
// draws nothing.
func collectCumulativeGroups(matrix promql.Matrix) (groups map[string]*promHeatmapGroup, groupOrder []string) {
groups = map[string]*promHeatmapGroup{}

View File

@@ -16,7 +16,7 @@ import (
// The cache key is the fingerprint alone, so two request types over one
// expression must not produce the same one — a time series payload served to a
// heatmap request has no axis and reads back as a single collapsed band.
// heatmap request has no axis and reads back as a single collapsed bucket.
func TestFingerprintSeparatesHeatmapFromTimeSeries(t *testing.T) {
fingerprintFor := func(requestType qbv5.RequestType) string {
q := &promqlQuery{
@@ -50,7 +50,7 @@ func TestFoldMatrixAsHeatmapClampsADecreasingCumulativeCount(t *testing.T) {
},
}
data, err := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
data, err := foldMatrixAsHeatmap(matrix, "A")
require.NoError(t, err)
require.Len(t, data.Aggregations, 1)
@@ -76,7 +76,7 @@ func TestFoldMatrixAsHeatmapWidensTheBandOverAMissingUpperBound(t *testing.T) {
},
}
data, err := foldMatrixAsHeatmap(matrix, &qbv5.TimeRange{From: 1710000000000, To: 1710000060000}, uint64(time.Minute.Milliseconds()), "A")
data, err := foldMatrixAsHeatmap(matrix, "A")
require.NoError(t, err)
require.Len(t, data.Aggregations, 1)

View File

@@ -95,13 +95,18 @@ func enhancePromQLError(query string, parseErr error) error {
}
type promqlQuery struct {
logger *slog.Logger
promEngine prometheus.Prometheus
parser parser.Parser
query qbv5.PromQuery
tr qbv5.TimeRange
requestType qbv5.RequestType
vars map[string]qbv5.VariableItem
logger *slog.Logger
promEngine prometheus.Prometheus
parser parser.Parser
query qbv5.PromQuery
// tr is the evaluation range: instants tr.From, tr.From+step, ... <= tr.To.
tr qbv5.TimeRange
// requestWindow is the window of the request this query answers. A
// query ranged over a gap of it renders $start_timestamp and friends
// from here, not from the gap.
requestWindow qbv5.TimeRange
requestType qbv5.RequestType
vars map[string]qbv5.VariableItem
}
var _ qbv5.Query = (*promqlQuery)(nil)
@@ -116,16 +121,29 @@ func newPromqlQuery(
variables map[string]qbv5.VariableItem,
) *promqlQuery {
return &promqlQuery{
logger: logger,
promEngine: promEngine,
parser: prometheus.NewParser(),
query: query,
tr: tr,
requestType: requestType,
vars: variables,
logger: logger,
promEngine: promEngine,
parser: prometheus.NewParser(),
query: query,
tr: tr,
requestWindow: tr,
requestType: requestType,
vars: variables,
}
}
// ranged copies the query over a gap [from, to) of its request window as the
// cache reports it: to is exclusive on the step grid, so the last instant to
// evaluate is one step before it.
func (q *promqlQuery) ranged(gap qbv5.TimeRange) *promqlQuery {
copied := *q
copied.query = q.query.Copy()
copied.tr = qbv5.TimeRange{From: gap.From, To: gap.To - uint64(q.query.Step.Milliseconds())}
return &copied
}
func (q *promqlQuery) stepMs() uint64 { return uint64(q.query.Step.Milliseconds()) }
func (q *promqlQuery) Fingerprint() string {
switch q.requestType {
case qbv5.RequestTypeTimeSeries, qbv5.RequestTypeHeatmap:
@@ -133,11 +151,27 @@ func (q *promqlQuery) Fingerprint() string {
return ""
}
query, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
// Evaluation instants are start + k*step. Only a start on the step grid
// shares instants with other windows of the same query; anything else is
// served without the cache rather than mixed with grid points.
if stepMs := q.stepMs(); stepMs == 0 || q.tr.From%stepMs != 0 {
return ""
}
query, err := q.renderVars(q.query.Query, q.vars, q.requestWindow.From, q.requestWindow.To)
if err != nil {
q.logger.ErrorContext(context.TODO(), "failed render template variables", slog.String("query", q.query.Query))
return ""
}
// @ start() and @ end() resolve to the window of the evaluation, so a
// piece of the window evaluates something else than the whole.
exprParser := q.parser
if exprParser == nil {
exprParser = prometheus.NewParser()
}
if expr, err := exprParser.ParseExpr(query); err != nil || usesStartOrEnd(expr) {
return ""
}
parts := []string{
"promql",
// one expression returns a different shape per request type
@@ -149,8 +183,30 @@ func (q *promqlQuery) Fingerprint() string {
return strings.Join(parts, "&")
}
func usesStartOrEnd(expr parser.Expr) bool {
found := false
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
switch n := node.(type) {
case *parser.VectorSelector:
found = found || n.StartOrEnd != 0
case *parser.SubqueryExpr:
found = found || n.StartOrEnd != 0
}
return nil
})
return found
}
// Window is the range of instants the query evaluates, half-open on the step
// grid: the last instant is tr.To (or the last grid point before it), and the
// window ends one step after it.
func (q *promqlQuery) Window() (uint64, uint64) {
return q.tr.From, q.tr.To
stepMs := q.stepMs()
if stepMs == 0 || q.tr.To < q.tr.From {
return q.tr.From, q.tr.To
}
last := q.tr.From + (q.tr.To-q.tr.From)/stepMs*stepMs
return q.tr.From, last + stepMs
}
// removeAllVarMatchers removes label matchers from a PromQL query that reference variables with __all__ value.
@@ -236,7 +292,7 @@ func (q *promqlQuery) renderVars(query string, vars map[string]qbv5.VariableItem
// Statement renders the PromQL string (no SQL args) without executing it, for
// the preview path.
func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
rendered, err := q.renderVars(q.query.Query, q.vars, q.requestWindow.From, q.requestWindow.To)
if err != nil {
return nil, err
}
@@ -246,7 +302,7 @@ func (q *promqlQuery) Statement(_ context.Context) (*qbv5.Statement, error) {
// PreviewStatements returns the ClickHouse statement(s) this PromQL query
// would run on the engine path, captured without executing them.
func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.CapturedStatement, error) {
rendered, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
rendered, err := q.renderVars(q.query.Query, q.vars, q.requestWindow.From, q.requestWindow.To)
if err != nil {
return nil, err
}
@@ -271,7 +327,7 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
start := int64(querybuilder.ToNanoSecs(q.tr.From))
end := int64(querybuilder.ToNanoSecs(q.tr.To))
query, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
query, err := q.renderVars(q.query.Query, q.vars, q.requestWindow.From, q.requestWindow.To)
if err != nil {
return nil, err
}
@@ -352,7 +408,7 @@ func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began ti
}
func (q *promqlQuery) toResultForHeatmap(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) (*qbv5.Result, error) {
tsData, err := foldMatrixAsHeatmap(matrix, &q.tr, uint64(q.query.Step.Milliseconds()), q.query.Name)
tsData, err := foldMatrixAsHeatmap(matrix, q.query.Name)
if err != nil {
return nil, err
}

View File

@@ -448,6 +448,38 @@ func TestQuotedMetricOutsideBracesPattern(t *testing.T) {
}
}
// promql reports at the window start and every step after it, so only a
// window that starts on the step grid shares instants with other windows of
// the same query and is cached.
func TestFingerprintCachesOnlyWindowsOnTheStepGrid(t *testing.T) {
minuteStep := qbv5.Step{Duration: time.Minute}
fingerprint := func(tr qbv5.TimeRange) string {
return newPromqlQuery(slog.Default(), nil, qbv5.PromQuery{Query: "up", Step: minuteStep}, tr, qbv5.RequestTypeTimeSeries, nil).Fingerprint()
}
onTheMinute := fingerprint(qbv5.TimeRange{From: 600_000, To: 1_200_000})
halfAStepLater := fingerprint(qbv5.TimeRange{From: 630_000, To: 1_230_000})
aWholeMinuteLater := fingerprint(qbv5.TimeRange{From: 900_000, To: 1_500_000})
require.NotEmpty(t, onTheMinute)
assert.Empty(t, halfAStepLater, "a window off the grid is not cached")
assert.Equal(t, onTheMinute, aWholeMinuteLater, "windows whole steps apart report at the same instants")
}
func TestPromQLWindowIsHalfOpenOnTheStepGrid(t *testing.T) {
minuteStep := qbv5.Step{Duration: time.Minute}
window := func(tr qbv5.TimeRange) qbv5.TimeRange {
from, to := newPromqlQuery(slog.Default(), nil, qbv5.PromQuery{Query: "up", Step: minuteStep}, tr, qbv5.RequestTypeTimeSeries, nil).Window()
return qbv5.TimeRange{From: from, To: to}
}
assert.Equal(t, qbv5.TimeRange{From: 600_000, To: 1_260_000}, window(qbv5.TimeRange{From: 600_000, To: 1_200_000}), "the instant at the end is evaluated and lies inside the window")
assert.Equal(t, qbv5.TimeRange{From: 600_000, To: 1_260_000}, window(qbv5.TimeRange{From: 600_000, To: 1_230_000}), "the last instant is the last grid point at or before the end")
ranged := newPromqlQuery(slog.Default(), nil, qbv5.PromQuery{Query: "up", Step: minuteStep}, qbv5.TimeRange{From: 600_000, To: 1_200_000}, qbv5.RequestTypeTimeSeries, nil).ranged(qbv5.TimeRange{From: 900_000, To: 1_260_000})
assert.Equal(t, qbv5.TimeRange{From: 900_000, To: 1_200_000}, ranged.tr, "a gap of the window evaluates up to the instant before its end")
}
func TestToResultDropsNonFiniteValues(t *testing.T) {
tests := []struct {
description string

View File

@@ -22,6 +22,8 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
@@ -218,7 +220,11 @@ func (q *querier) buildQueries(
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
}
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
timeRange := qbtypes.TimeRange{From: req.Start, To: req.End}
if !req.NoStepAlignment {
timeRange = alignWindowToStep(timeRange, promQuery.Step)
}
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, timeRange, req.RequestType, tmplVars)
queries[promQuery.Name] = promqlQuery
steps[promQuery.Name] = promQuery.Step
case qbtypes.QueryTypeClickHouseSQL:
@@ -671,13 +677,21 @@ func (q *querier) run(
eg, egCtx := errgroup.WithContext(ctx)
for i, name := range names {
query := qs[name]
eg.Go(func() error {
eg.Go(func() (err error) {
// A panic here would end the process: errgroup does not recover
// and the HTTP recovery middleware only covers the handler goroutine.
defer func() {
if r := recover(); r != nil {
q.logger.ErrorContext(egCtx, "query execution panicked", slog.String("query", name), slog.Any("panic", r))
err = errors.NewInternalf(errors.CodeInternal, "query %s failed", name)
}
}()
// Skip cache if NoCache is set, or if cache is not available
if req.NoCache || q.bucketCache == nil || query.Fingerprint() == "" {
if req.NoCache {
q.logger.DebugContext(egCtx, "NoCache flag set, bypassing cache", slog.String("query", name))
} else {
q.logger.InfoContext(egCtx, "no bucket cache or fingerprint, executing query", slog.String("fingerprint", query.Fingerprint()))
q.logger.DebugContext(egCtx, "no bucket cache or fingerprint, executing query", slog.String("query", name))
}
sem <- struct{}{}
result, err := query.Execute(egCtx)
@@ -777,120 +791,107 @@ func (q *querier) run(
return resp, nil
}
// executeWithCache executes a query using the bucket cache. sem limits how
// many queries run at once for the whole request.
// executeWithCache serves a query from the bucket cache: the cached part of
// the window plus one statement per missing range, merged and written back
// range by range. sem limits how many statements run at once for the whole
// request.
func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query qbtypes.Query, step qbtypes.Step, sem chan struct{}) (*qbtypes.Result, error) {
// Get cached data and missing ranges
cachedResult, missingRanges := q.bucketCache.GetMissRanges(ctx, orgID, query, step)
// If no missing ranges, return cached result
if len(missingRanges) == 0 && cachedResult != nil {
return cachedResult, nil
from, to := query.Window()
stepMs := uint64(step.Milliseconds())
// Functions such as runningDiff need the step before the window; the
// cache window includes it so a hit carries it too.
lookbackMs := uint64(lookbackSteps(query)) * stepMs
req := CacheRequest{
Key: CacheKey(query.Fingerprint()),
Window: qbtypes.TimeRange{From: from - min(lookbackMs, from), To: to},
Step: step,
Kind: queryKind(query),
TrimHeatmapAxis: trimsHeatmapAxis(query),
}
// If entire range is missing, execute normally
if cachedResult == nil && len(missingRanges) == 1 {
startMs, endMs := query.Window()
if missingRanges[0].From == startMs && missingRanges[0].To == endMs {
sem <- struct{}{}
result, err := query.Execute(ctx)
<-sem
if err != nil {
return nil, err
}
// Store in cache for future use
q.bucketCache.Put(ctx, orgID, query, step, result)
return result, nil
execute := func(qry qbtypes.Query) (*qbtypes.Result, error) {
sem <- struct{}{}
defer func() { <-sem }()
return qry.Execute(ctx)
}
cached, missing := q.bucketCache.GetMissRanges(ctx, orgID, req)
if len(missing) == 0 && cached != nil {
flagPartialPoints(query, cached, from, to, stepMs)
return cached, nil
}
// A statement that ranks or limits over its window cannot be assembled
// from pieces, and a window that is entirely missing is cheaper as one
// statement; both run the original query over its own window.
entirelyMissing := cached == nil && len(missing) == 1 && missing[0] == req.Window
if entirelyMissing || wholeWindowOnly(query) || len(missing) == 0 {
result, err := execute(query)
if err != nil {
return nil, err
}
q.bucketCache.Put(ctx, orgID, req, req.Window, result)
return result, nil
}
// Execute queries for missing ranges with bounded parallelism
freshResults := make([]*qbtypes.Result, len(missingRanges))
errs := make([]error, len(missingRanges))
totalStats := qbtypes.ExecStats{}
q.logger.DebugContext(ctx, "executing queries for missing ranges",
slog.Int("missing_ranges_count", len(missingRanges)),
slog.Any("ranges", missingRanges))
fresh := make([]*qbtypes.Result, len(missing))
errs := make([]error, len(missing))
var wg sync.WaitGroup
for i, timeRange := range missingRanges {
for i, timeRange := range missing {
wg.Add(1)
go func(idx int, tr *qbtypes.TimeRange) {
go func(i int, timeRange qbtypes.TimeRange) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
// Create a new query with the missing time range
rangedQuery := q.createRangedQuery(orgID, query, *tr)
if rangedQuery == nil {
errs[idx] = errors.NewInternalf(errors.CodeInternal, "failed to create ranged query for range %d-%d", tr.From, tr.To)
ranged := q.createRangedQuery(query, timeRange)
if ranged == nil {
errs[i] = errors.NewInternalf(errors.CodeInternal, "cannot range query over %d-%d", timeRange.From, timeRange.To)
return
}
// Execute the ranged query
result, err := rangedQuery.Execute(ctx)
if err != nil {
errs[idx] = err
return
}
freshResults[idx] = result
fresh[i], errs[i] = execute(ranged)
}(i, timeRange)
}
// Wait for all queries to complete
wg.Wait()
// Check for errors
for _, err := range errs {
if err != nil {
// If any query failed, fall back to full execution
q.logger.ErrorContext(ctx, "parallel query execution failed", errors.Attr(err))
sem <- struct{}{}
result, err := query.Execute(ctx)
<-sem
if err != nil {
return nil, err
}
q.bucketCache.Put(ctx, orgID, query, step, result)
return result, nil
return nil, err
}
}
// Calculate total stats and filter out nil results
validResults := make([]*qbtypes.Result, 0, len(freshResults))
for _, result := range freshResults {
if result != nil {
validResults = append(validResults, result)
totalStats.RowsScanned += result.Stats.RowsScanned
totalStats.BytesScanned += result.Stats.BytesScanned
totalStats.DurationMS += result.Stats.DurationMS
}
merged := mergeResults(req, cached, fresh)
for i, timeRange := range missing {
q.bucketCache.Put(ctx, orgID, req, timeRange, fresh[i])
}
freshResults = validResults
// Merge cached and fresh results
mergedResult := q.mergeResults(cachedResult, freshResults)
mergedResult.Stats.RowsScanned += totalStats.RowsScanned
mergedResult.Stats.BytesScanned += totalStats.BytesScanned
mergedResult.Stats.DurationMS += totalStats.DurationMS
// Store merged result in cache
q.bucketCache.Put(ctx, orgID, query, step, mergedResult)
return mergedResult, nil
flagPartialPoints(query, merged, from, to, stepMs)
return merged, nil
}
// createRangedQuery creates a copy of the query with a different time range.
func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query, timeRange qbtypes.TimeRange) qbtypes.Query {
// this is called in a goroutine, so we create a copy of the query to avoid race conditions
switch qt := originalQuery.(type) {
// flagPartialPoints marks the points of a served result the way consume
// marks them for the query's own window: a bucket holds the flag of the
// window that fetched it, and a piece is fetched over a window of its own.
// PromQL evaluates instants and has no partial points.
func flagPartialPoints(query qbtypes.Query, result *qbtypes.Result, from, to, stepMs uint64) {
if _, ok := query.(*promqlQuery); ok || result == nil {
return
}
data, ok := result.Value.(*qbtypes.TimeSeriesData)
if !ok || data == nil {
return
}
window := &qbtypes.TimeRange{From: from, To: to}
for _, agg := range data.Aggregations {
for _, s := range agg.Series {
for _, v := range s.Values {
v.Partial = isPartialValue(v.Timestamp, window, stepMs)
}
}
}
}
// createRangedQuery copies a query over another window. The window is in the
// query's own clock: a timeShift query already reports a shifted window, so
// the copy takes the range as it is.
func (q *querier) createRangedQuery(original qbtypes.Query, timeRange qbtypes.TimeRange) qbtypes.Query {
switch qt := original.(type) {
case *promqlQuery:
queryCopy := qt.query.Copy()
return newPromqlQuery(q.logger, qt.promEngine, queryCopy, timeRange, qt.requestType, qt.vars)
return qt.ranged(timeRange)
case *chSQLQuery:
queryCopy := qt.query.Copy()
@@ -899,40 +900,34 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
return newchSQLQuery(q.logger, q.telemetryStore, queryCopy, argsCopy, timeRange, qt.kind, qt.vars)
case *builderQuery[qbtypes.TraceAggregation]:
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
// reuse the original query's statement builder and type so an AI query
// keeps its AI builder and cache key
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, qt.builderConfig)
case *builderQuery[qbtypes.LogAggregation]:
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
shiftStmtBuilder := q.logStmtBuilder
if qt.spec.Source == telemetrytypes.SourceAudit {
shiftStmtBuilder = q.auditStmtBuilder
}
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, qt.spec.Copy(), timeRange, qt.kind, qt.variables, qt.builderConfig)
case *builderQuery[qbtypes.MetricAggregation]:
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
if qt.spec.Source == telemetrytypes.SourceMeter {
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
// The builder picks its tables from the window it is given; a piece
// must read the tables the whole request reads.
for i, agg := range specCopy.Aggregations {
if specCopy.Source == telemetrytypes.SourceMeter {
specCopy.Aggregations[i].TableHints = metertelemetryschema.TableHintsForWindow(qt.fromMS, qt.toMS, agg.Type, agg.TimeAggregation, agg.TableHints)
} else {
specCopy.Aggregations[i].TableHints = metricstelemetryschema.TableHintsForWindow(qt.fromMS, qt.toMS, agg.Type, agg.TimeAggregation, agg.Reduced, agg.TableHints)
}
}
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, timeRange, qt.kind, qt.variables, qt.builderConfig)
case *traceOperatorQuery:
specCopy := qt.spec.Copy()
return &traceOperatorQuery{
telemetryStore: q.telemetryStore,
orgID: qt.orgID,
stmtBuilder: q.traceOperatorStmtBuilder,
spec: specCopy,
fromMS: uint64(timeRange.From),
toMS: uint64(timeRange.To),
spec: qt.spec.Copy(),
fromMS: timeRange.From,
toMS: timeRange.To,
compositeQuery: qt.compositeQuery,
kind: qt.kind,
}
@@ -941,217 +936,117 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
}
}
// mergeResults merges cached result with fresh results.
func (q *querier) mergeResults(cached *qbtypes.Result, fresh []*qbtypes.Result) *qbtypes.Result {
if cached == nil {
if len(fresh) == 1 {
return fresh[0]
// mergeResults joins the cached part with the fresh pieces. Fresh points win
// over cached points at the same timestamp, and a partial point never wins
// over a whole one (a metrics piece returns the step before its range as a
// partial point that the cached part already holds whole).
func mergeResults(req CacheRequest, cached *qbtypes.Result, fresh []*qbtypes.Result) *qbtypes.Result {
merged := &qbtypes.Result{Type: req.Kind}
parts := make([]*qbtypes.TimeSeriesData, 0, len(fresh)+1)
add := func(result *qbtypes.Result) {
if result == nil {
return
}
if len(fresh) == 0 {
return nil
if data, ok := result.Value.(*qbtypes.TimeSeriesData); ok && data != nil {
parts = append(parts, data)
}
// If cached is nil but we have multiple fresh results, we need to merge them
// We need to merge all fresh results properly to avoid duplicates
merged := &qbtypes.Result{
Type: fresh[0].Type,
Stats: fresh[0].Stats,
Warnings: fresh[0].Warnings,
WarningsDocURL: fresh[0].WarningsDocURL,
merged.Stats.RowsScanned += result.Stats.RowsScanned
merged.Stats.BytesScanned += result.Stats.BytesScanned
merged.Stats.DurationMS += result.Stats.DurationMS
merged.Warnings = append(merged.Warnings, result.Warnings...)
if merged.WarningsDocURL == "" {
merged.WarningsDocURL = result.WarningsDocURL
}
// Merge all fresh results including the first one
switch merged.Type {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
// Pass nil as cached value to ensure proper merging of all fresh results
merged.Value = q.mergeTimeSeriesResults(nil, fresh)
}
return merged
}
// Start with cached result
merged := &qbtypes.Result{
Type: cached.Type,
Value: cached.Value,
Stats: cached.Stats,
Warnings: cached.Warnings,
WarningsDocURL: cached.WarningsDocURL,
add(cached)
for _, result := range fresh {
add(result)
}
// If no fresh results, return cached
if len(fresh) == 0 {
return merged
}
switch merged.Type {
case qbtypes.RequestTypeTimeSeries, qbtypes.RequestTypeHeatmap:
merged.Value = q.mergeTimeSeriesResults(cached.Value.(*qbtypes.TimeSeriesData), fresh)
}
if len(fresh) > 0 {
totalWarnings := len(merged.Warnings)
for _, result := range fresh {
totalWarnings += len(result.Warnings)
}
allWarnings := make([]string, 0, totalWarnings)
allWarnings = append(allWarnings, merged.Warnings...)
for _, result := range fresh {
allWarnings = append(allWarnings, result.Warnings...)
}
merged.Warnings = allWarnings
}
merged.Warnings = dedupeWarnings(merged.Warnings)
stepMs := uint64(req.Step.Milliseconds())
windowStart := req.Window.From - req.Window.From%stepMs
// A piece widened by the builder reports points before its own range;
// only the ones inside the request window (and its partial first step)
// belong to the answer, as with one statement over the whole window.
merged.Value = selectPoints(mergeTimeSeriesData(parts), func(v *qbtypes.TimeSeriesValue) bool {
ts := uint64(v.Timestamp)
return ts >= windowStart && ts < req.Window.To
})
return merged
}
func mergeBucketUpperBounds(cachedValue *qbtypes.TimeSeriesData, freshResults []*qbtypes.Result) map[int][]float64 {
upperBoundSources := make([]*qbtypes.TimeSeriesData, 0, len(freshResults)+1)
upperBoundSources = append(upperBoundSources, cachedValue)
for _, result := range freshResults {
freshTS, _ := result.Value.(*qbtypes.TimeSeriesData)
upperBoundSources = append(upperBoundSources, freshTS)
// alignWindowToStep moves both ends of a window down to the step grid, the
// way a query frontend does before a results cache: PromQL evaluates at
// start + k*step, so only windows on one grid share instants.
func alignWindowToStep(window qbtypes.TimeRange, step qbtypes.Step) qbtypes.TimeRange {
stepMs := uint64(step.Milliseconds())
if stepMs == 0 {
return window
}
return qbtypes.MergeBucketUpperBounds(upperBoundSources...)
return qbtypes.TimeRange{From: window.From - window.From%stepMs, To: window.To - window.To%stepMs}
}
// mergeTimeSeriesResults merges time series data.
func (q *querier) mergeTimeSeriesResults(cachedValue *qbtypes.TimeSeriesData, freshResults []*qbtypes.Result) *qbtypes.TimeSeriesData {
// queryKind is the request type a query answers with.
func queryKind(query qbtypes.Query) qbtypes.RequestType {
switch qt := query.(type) {
case *promqlQuery:
return qt.requestType
case *builderQuery[qbtypes.TraceAggregation]:
return qt.kind
case *builderQuery[qbtypes.LogAggregation]:
return qt.kind
case *builderQuery[qbtypes.MetricAggregation]:
return qt.kind
case *chSQLQuery:
return qt.kind
case *traceOperatorQuery:
return qt.kind
}
return qbtypes.RequestTypeTimeSeries
}
// Map to store merged series by aggregation index and series key
seriesMap := make(map[int]map[string]*qbtypes.TimeSeries)
// Map to store aggregation bucket metadata
bucketMetadata := make(map[int]*qbtypes.AggregationBucket)
// wholeWindowOnly reports whether the query's statement depends on the
// whole window, so its cached result serves only the identical window.
func wholeWindowOnly(query qbtypes.Query) bool {
switch qt := query.(type) {
case *builderQuery[qbtypes.TraceAggregation]:
return qt.wholeWindowOnly()
case *builderQuery[qbtypes.LogAggregation]:
return qt.wholeWindowOnly()
case *builderQuery[qbtypes.MetricAggregation]:
return qt.wholeWindowOnly()
}
return false
}
mergedUpperBounds := mergeBucketUpperBounds(cachedValue, freshResults)
// lookbackSteps is how many steps before the window the answer must carry.
func lookbackSteps(query qbtypes.Query) int {
if qt, ok := query.(*builderQuery[qbtypes.MetricAggregation]); ok {
return qt.lookbackSteps()
}
return 0
}
// Process cached data if available
if cachedValue != nil && cachedValue.Aggregations != nil {
for _, aggBucket := range cachedValue.Aggregations {
if seriesMap[aggBucket.Index] == nil {
seriesMap[aggBucket.Index] = make(map[string]*qbtypes.TimeSeries)
}
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
if bucketMetadata[aggBucket.Index] == nil {
bucketMetadata[aggBucket.Index] = aggBucket
}
for _, series := range aggBucket.Series {
key := qbtypes.GetUniqueSeriesKey(series.Labels)
if existingSeries, ok := seriesMap[aggBucket.Index][key]; ok {
// Merge values from duplicate series in cached data, avoiding duplicate timestamps
timestampMap := make(map[int64]bool)
for _, v := range existingSeries.Values {
timestampMap[v.Timestamp] = true
}
// Only add values with new timestamps
for _, v := range series.Values {
if !timestampMap[v.Timestamp] {
existingSeries.Values = append(existingSeries.Values, v)
}
}
} else {
// Create a copy to avoid modifying the cached data
seriesCopy := &qbtypes.TimeSeries{
Labels: series.Labels,
Values: make([]*qbtypes.TimeSeriesValue, len(series.Values)),
}
copy(seriesCopy.Values, series.Values)
seriesMap[aggBucket.Index][key] = seriesCopy
}
// trimsHeatmapAxis reports whether the query computes its heatmap axis from
// the served columns. A histogram metric, promql and clickhouse name their
// own buckets, and an empty one of theirs still belongs on the axis.
func trimsHeatmapAxis(query qbtypes.Query) bool {
switch qt := query.(type) {
case *builderQuery[qbtypes.TraceAggregation]:
return qt.kind == qbtypes.RequestTypeHeatmap
case *builderQuery[qbtypes.LogAggregation]:
return qt.kind == qbtypes.RequestTypeHeatmap
case *builderQuery[qbtypes.MetricAggregation]:
if qt.kind != qbtypes.RequestTypeHeatmap {
return false
}
for _, agg := range qt.spec.Aggregations {
if agg.HeatmapBucketing != nil {
return true
}
}
}
// Add fresh series
for _, result := range freshResults {
freshTS, ok := result.Value.(*qbtypes.TimeSeriesData)
if !ok || freshTS == nil || freshTS.Aggregations == nil {
continue
}
for _, aggBucket := range freshTS.Aggregations {
if seriesMap[aggBucket.Index] == nil {
seriesMap[aggBucket.Index] = make(map[string]*qbtypes.TimeSeries)
}
// Prefer fresh metadata over cached metadata
if aggBucket.Alias != "" || aggBucket.Meta.Unit != "" {
bucketMetadata[aggBucket.Index] = aggBucket
} else if bucketMetadata[aggBucket.Index] == nil {
bucketMetadata[aggBucket.Index] = aggBucket
}
}
for _, aggBucket := range freshTS.Aggregations {
aggBucket.ReindexValuesToNewUpperBounds(mergedUpperBounds[aggBucket.Index])
for _, series := range aggBucket.Series {
key := qbtypes.GetUniqueSeriesKey(series.Labels)
if existingSeries, ok := seriesMap[aggBucket.Index][key]; ok {
// Merge values, avoiding duplicate timestamps
// Create a map to track existing timestamps
timestampMap := make(map[int64]bool)
for _, v := range existingSeries.Values {
timestampMap[v.Timestamp] = true
}
// Only add values with new timestamps
for _, v := range series.Values {
if !timestampMap[v.Timestamp] {
existingSeries.Values = append(existingSeries.Values, v)
}
}
} else {
// New series
seriesMap[aggBucket.Index][key] = series
}
}
}
}
result := &qbtypes.TimeSeriesData{
Aggregations: []*qbtypes.AggregationBucket{},
}
// Set QueryName from cached or first fresh result
if cachedValue != nil {
result.QueryName = cachedValue.QueryName
} else if len(freshResults) > 0 {
if freshTS, ok := freshResults[0].Value.(*qbtypes.TimeSeriesData); ok && freshTS != nil {
result.QueryName = freshTS.QueryName
}
}
for index, series := range seriesMap {
var aggSeries []*qbtypes.TimeSeries
for _, s := range series {
// Sort values by timestamp
slices.SortFunc(s.Values, func(a, b *qbtypes.TimeSeriesValue) int {
if a.Timestamp < b.Timestamp {
return -1
}
if a.Timestamp > b.Timestamp {
return 1
}
return 0
})
aggSeries = append(aggSeries, s)
}
// Preserve bucket metadata from either cached or fresh results
bucket := &qbtypes.AggregationBucket{
Index: index,
Series: aggSeries,
}
if metadata, ok := bucketMetadata[index]; ok {
bucket.Alias = metadata.Alias
bucket.Meta = metadata.Meta
}
result.Aggregations = append(result.Aggregations, bucket)
}
return result
return false
}
func secondsStep(s uint64) qbtypes.Step {

View File

@@ -0,0 +1,551 @@
package querier
import (
"context"
"fmt"
"regexp"
"sync"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/prometheustest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// windowLogStmtBuilder stands in for the logs statement builder. It records
// every window it is asked to build and renders the window and the limit
// into the SQL text so the ClickHouse mock can answer each statement.
type windowLogStmtBuilder struct {
mu sync.Mutex
ranges []qbtypes.TimeRange
}
func (b *windowLogStmtBuilder) Build(_ context.Context, _ valuer.UUID, start, end uint64, _ qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], _ map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.ranges = append(b.ranges, qbtypes.TimeRange{From: start, To: end})
return &qbtypes.Statement{Query: windowSQL(start, end, query.Limit)}, nil
}
func (b *windowLogStmtBuilder) built() []qbtypes.TimeRange {
b.mu.Lock()
defer b.mu.Unlock()
return append([]qbtypes.TimeRange(nil), b.ranges...)
}
func windowSQL(start, end uint64, limit int) string {
return fmt.Sprintf("SELECT ts, `service.name`, __result_0 FROM logs WHERE range = '%d-%d' AND lim = %d", start, end, limit)
}
var windowColumns = []cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "service.name", Type: "String"},
{Name: "__result_0", Type: "Float64"},
}
// windowRows renders one row per minute and service in [start, end).
func windowRows(start, end uint64, services []string, valueAt func(ts uint64, service string) float64) *cmock.Rows {
var values [][]any
for ts := start; ts < end; ts += minuteStepMs {
for _, service := range services {
values = append(values, []any{time.UnixMilli(int64(ts)), service, valueAt(ts, service)})
}
}
return cmock.NewRows(windowColumns, values)
}
func expectWindowQuery(store *telemetrystoretest.Provider, start, end uint64, limit int, rows *cmock.Rows) {
store.Mock().ExpectQuery(regexp.QuoteMeta(windowSQL(start, end, limit))).WillReturnRows(rows)
}
func logSpec(limit int) qbtypes.QueryBuilderQuery[qbtypes.LogAggregation] {
spec := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "A",
Signal: telemetrytypes.SignalLogs,
StepInterval: minuteStep(),
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name", FieldDataType: telemetrytypes.FieldDataTypeString, FieldContext: telemetrytypes.FieldContextResource,
}}},
}
if limit > 0 {
spec.Limit = limit
spec.Order = []qbtypes.OrderBy{{
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "count()"}},
Direction: qbtypes.OrderDirectionDesc,
}}
}
return spec
}
func newCachingQuerier(t *testing.T, builder *windowLogStmtBuilder, store *telemetrystoretest.Provider) *querier {
t.Helper()
return &querier{
logger: instrumentationtest.New().Logger(),
fl: flaggertest.New(t),
telemetryStore: store,
logStmtBuilder: builder,
bucketCache: createTestBucketCache(t),
maxConcurrentQueries: DefaultMaxConcurrentQueries,
}
}
func logRequest(startMs, endMs uint64, spec qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) *qbtypes.QueryRangeRequest {
return &qbtypes.QueryRangeRequest{
Start: startMs,
End: endMs,
RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{{Type: qbtypes.QueryTypeBuilder, Spec: spec}},
},
}
}
func runLogRequest(t *testing.T, q *querier, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, spec qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) *qbtypes.TimeSeriesData {
t.Helper()
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.logStmtBuilder, qbtypes.QueryTypeBuilder, spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, nil, builderConfig{})
resp, err := q.run(context.Background(), orgID, map[string]qbtypes.Query{spec.Name: bq}, req, map[string]qbtypes.Step{spec.Name: spec.StepInterval}, &qbtypes.QBEvent{}, nil)
require.NoError(t, err)
require.Len(t, resp.Data.Results, 1)
tsData, ok := resp.Data.Results[0].(*qbtypes.TimeSeriesData)
require.True(t, ok, "result is %T", resp.Data.Results[0])
return tsData
}
func pointsByService(data *qbtypes.TimeSeriesData) map[string][]float64 {
out := map[string][]float64{}
for _, agg := range data.Aggregations {
for _, s := range agg.Series {
name := fmt.Sprint(s.Labels[0].Value)
for _, v := range s.Values {
out[name] = append(out[name], v.Value)
}
}
}
return out
}
// A timeShift query already reports its window in the shifted clock, so a
// gap of that window is fetched as it is.
func TestCreateRangedQuery_TimeShiftIsAppliedOnce(t *testing.T) {
q := &querier{logger: instrumentationtest.New().Logger(), logStmtBuilder: &windowLogStmtBuilder{}}
spec := logSpec(0)
spec.Functions = []qbtypes.Function{{Name: qbtypes.FunctionNameTimeShift, Args: []qbtypes.FunctionArg{{Value: 3600.0}}}}
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
requested := qbtypes.TimeRange{From: epochMs, To: epochMs + 60*minuteStepMs}
shifted := adjustTimeRangeForShift(spec, requested, qbtypes.RequestTypeTimeSeries)
require.Equal(t, requested.From-3_600_000, shifted.From)
bq := newBuilderQuery(q.logger, nil, valuer.GenerateUUID(), q.logStmtBuilder, qbtypes.QueryTypeBuilder, spec, shifted, qbtypes.RequestTypeTimeSeries, nil, builderConfig{})
missing := qbtypes.TimeRange{From: shifted.From + 30*minuteStepMs, To: shifted.To}
ranged := q.createRangedQuery(bq, missing)
require.NotNil(t, ranged)
from, to := ranged.Window()
assert.Equal(t, missing, qbtypes.TimeRange{From: from, To: to})
}
func TestMergeResults_FreshPointReplacesCachedPoint(t *testing.T) {
ts := int64(epochMs)
point := func(value float64, partial bool) *qbtypes.Result {
return seriesResult(1, &qbtypes.TimeSeries{
Labels: []*qbtypes.Label{{Key: telemetrytypes.TelemetryFieldKey{Name: "service"}, Value: "a"}},
Values: []*qbtypes.TimeSeriesValue{{Timestamp: ts, Value: value, Partial: partial}},
})
}
req := CacheRequest{Window: qbtypes.TimeRange{From: epochMs, To: epochMs + minuteStepMs}, Step: minuteStep(), Kind: qbtypes.RequestTypeTimeSeries}
merged := mergeResults(req, point(3, false), []*qbtypes.Result{point(7, false)})
assert.Equal(t, map[string][]float64{"a": {7}}, pointsByService(merged.Value.(*qbtypes.TimeSeriesData)))
assert.Equal(t, uint64(2), merged.Stats.RowsScanned)
merged = mergeResults(req, point(3, false), []*qbtypes.Result{point(7, true)})
assert.Equal(t, map[string][]float64{"a": {3}}, pointsByService(merged.Value.(*qbtypes.TimeSeriesData)), "a partial point never replaces a whole one")
}
func TestMergeResults_DropsPointsBeforeTheWindow(t *testing.T) {
req := CacheRequest{Window: qbtypes.TimeRange{From: epochMs + 30_000, To: epochMs + 3*minuteStepMs}, Step: minuteStep(), Kind: qbtypes.RequestTypeTimeSeries}
fresh := seriesResult(1, minuteSeries("a", epochMs-2*minuteStepMs, epochMs+3*minuteStepMs, 1))
merged := mergeResults(req, nil, []*qbtypes.Result{fresh})
assert.Equal(t, map[string][]float64{"a": {1, 1, 1}}, pointsByService(merged.Value.(*qbtypes.TimeSeriesData)), "the partial first step is kept, the widened lookback is not")
}
// A grouped query with a limit is a top-N over the requested window; its
// answer is cached for that window only and never assembled from pieces.
func TestRun_LimitedGroupByIsNeverAssembledFromPieces(t *testing.T) {
head := qbtypes.TimeRange{From: epochMs, To: epochMs + 10*minuteStepMs}
tail := qbtypes.TimeRange{From: head.To, To: head.To + 10*minuteStepMs}
full := qbtypes.TimeRange{From: head.From, To: tail.To}
// Top-2 of the head is {a, b}, of the tail {c, d}, of the full window {a, c}.
headCounts := map[string]float64{"a": 10, "b": 8, "c": 1, "d": 1}
tailCounts := map[string]float64{"a": 1, "b": 1, "c": 10, "d": 8}
valueAt := func(ts uint64, service string) float64 {
if ts < tail.From {
return headCounts[service]
}
return tailCounts[service]
}
store := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store.Mock().MatchExpectationsInOrder(false)
expectWindowQuery(store, head.From, head.To, 2, windowRows(head.From, head.To, []string{"a", "b"}, valueAt))
expectWindowQuery(store, tail.From, tail.To, 2, windowRows(tail.From, tail.To, []string{"c", "d"}, valueAt))
expectWindowQuery(store, full.From, full.To, 2, windowRows(full.From, full.To, []string{"a", "c"}, valueAt))
builder := &windowLogStmtBuilder{}
q := newCachingQuerier(t, builder, store)
orgID := valuer.GenerateUUID()
spec := logSpec(2)
runLogRequest(t, q, orgID, logRequest(head.From, head.To, spec), spec)
got := runLogRequest(t, q, orgID, logRequest(full.From, full.To, spec), spec)
assert.Equal(t, map[string][]float64{"a": append(repeat(10, 10), repeat(1, 10)...), "c": append(repeat(1, 10), repeat(10, 10)...)}, pointsByService(got))
assert.Equal(t, []qbtypes.TimeRange{head, full}, builder.built())
got = runLogRequest(t, q, orgID, logRequest(full.From, full.To, spec), spec)
assert.Len(t, builder.built(), 2, "the repeated window is a cache hit")
assert.Len(t, got.Aggregations[0].Series, 2)
}
func repeat(value float64, n int) []float64 {
out := make([]float64, n)
for i := range out {
out[i] = value
}
return out
}
// A request whose window the cache does not cover at all runs as one
// statement, whatever the grid alignment of its ends.
func TestExecuteWithCache_EntirelyMissingWindowRunsOneStatement(t *testing.T) {
store := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store.Mock().MatchExpectationsInOrder(false)
builder := &windowLogStmtBuilder{}
q := newCachingQuerier(t, builder, store)
orgID := valuer.GenerateUUID()
ctx := context.Background()
spec := logSpec(0)
one := func(uint64, string) float64 { return 1 }
older := qbtypes.TimeRange{From: epochMs - 120*minuteStepMs, To: epochMs - 60*minuteStepMs}
olderQuery := newBuilderQuery(q.logger, store, orgID, builder, qbtypes.QueryTypeBuilder, spec, older, qbtypes.RequestTypeTimeSeries, nil, builderConfig{})
req := CacheRequest{Key: CacheKey(olderQuery.Fingerprint()), Window: older, Step: spec.StepInterval, Kind: qbtypes.RequestTypeTimeSeries}
q.bucketCache.Put(ctx, orgID, req, older, seriesResult(1, minuteSeries("a", older.From, older.To, 1)))
window := qbtypes.TimeRange{From: epochMs + 7_000, To: epochMs + 60*minuteStepMs}
expectWindowQuery(store, window.From, window.To, 0, windowRows(window.From, window.To, []string{"a"}, one))
bq := newBuilderQuery(q.logger, store, orgID, builder, qbtypes.QueryTypeBuilder, spec, window, qbtypes.RequestTypeTimeSeries, nil, builderConfig{})
_, err := q.executeWithCache(ctx, orgID, bq, spec.StepInterval, make(chan struct{}, q.maxConcurrentQueries))
require.NoError(t, err)
assert.Equal(t, []qbtypes.TimeRange{window}, builder.built())
}
func TestExecuteWithCache_GapsRunAsSeparateStatementsAndAreWrittenBack(t *testing.T) {
store := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store.Mock().MatchExpectationsInOrder(false)
builder := &windowLogStmtBuilder{}
q := newCachingQuerier(t, builder, store)
orgID := valuer.GenerateUUID()
spec := logSpec(0)
one := func(uint64, string) float64 { return 1 }
middle := qbtypes.TimeRange{From: epochMs + 20*minuteStepMs, To: epochMs + 40*minuteStepMs}
whole := qbtypes.TimeRange{From: epochMs, To: epochMs + 60*minuteStepMs}
before := qbtypes.TimeRange{From: whole.From, To: middle.From}
after := qbtypes.TimeRange{From: middle.To, To: whole.To}
for _, window := range []qbtypes.TimeRange{middle, before, after} {
expectWindowQuery(store, window.From, window.To, 0, windowRows(window.From, window.To, []string{"a"}, one))
}
runLogRequest(t, q, orgID, logRequest(middle.From, middle.To, spec), spec)
got := runLogRequest(t, q, orgID, logRequest(whole.From, whole.To, spec), spec)
assert.Equal(t, map[string][]float64{"a": repeat(1, 60)}, pointsByService(got))
assert.ElementsMatch(t, []qbtypes.TimeRange{middle, before, after}, builder.built())
runLogRequest(t, q, orgID, logRequest(whole.From, whole.To, spec), spec)
assert.Len(t, builder.built(), 3, "the assembled window is a cache hit afterwards")
}
func TestExecuteWithCache_FailedGapFailsTheRequest(t *testing.T) {
store := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store.Mock().MatchExpectationsInOrder(false)
builder := &windowLogStmtBuilder{}
q := newCachingQuerier(t, builder, store)
orgID := valuer.GenerateUUID()
spec := logSpec(0)
one := func(uint64, string) float64 { return 1 }
cached := qbtypes.TimeRange{From: epochMs, To: epochMs + 20*minuteStepMs}
whole := qbtypes.TimeRange{From: epochMs, To: epochMs + 40*minuteStepMs}
gap := qbtypes.TimeRange{From: cached.To, To: whole.To}
expectWindowQuery(store, cached.From, cached.To, 0, windowRows(cached.From, cached.To, []string{"a"}, one))
store.Mock().ExpectQuery(regexp.QuoteMeta(windowSQL(gap.From, gap.To, 0))).WillReturnError(fmt.Errorf("clickhouse is away"))
runLogRequest(t, q, orgID, logRequest(cached.From, cached.To, spec), spec)
bq := newBuilderQuery(q.logger, store, orgID, builder, qbtypes.QueryTypeBuilder, spec, whole, qbtypes.RequestTypeTimeSeries, nil, builderConfig{})
_, err := q.executeWithCache(context.Background(), orgID, bq, spec.StepInterval, make(chan struct{}, q.maxConcurrentQueries))
require.Error(t, err)
assert.Equal(t, []qbtypes.TimeRange{cached, gap}, builder.built(), "the whole window is not run again after a failed gap")
}
func TestRun_FormulaByAliasSurvivesFullCacheHit(t *testing.T) {
window := qbtypes.TimeRange{From: epochMs, To: epochMs + 10*minuteStepMs}
store := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
three := func(uint64, string) float64 { return 3 }
expectWindowQuery(store, window.From, window.To, 0, windowRows(window.From, window.To, []string{"a"}, three))
q := newCachingQuerier(t, &windowLogStmtBuilder{}, store)
orgID := valuer.GenerateUUID()
spec := logSpec(0)
req := logRequest(window.From, window.To, spec)
req.CompositeQuery.Queries = append(req.CompositeQuery.Queries, qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeFormula,
Spec: qbtypes.QueryBuilderFormula{Name: "F", Expression: "[A.__result_0] * 2"},
})
formulaValues := func() []float64 {
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.logStmtBuilder, qbtypes.QueryTypeBuilder, spec, window, req.RequestType, nil, builderConfig{})
resp, err := q.run(context.Background(), orgID, map[string]qbtypes.Query{"A": bq}, req, map[string]qbtypes.Step{"A": spec.StepInterval}, &qbtypes.QBEvent{}, nil)
require.NoError(t, err)
for _, result := range resp.Data.Results {
tsData, ok := result.(*qbtypes.TimeSeriesData)
if !ok || tsData.QueryName != "F" {
continue
}
var values []float64
for _, agg := range tsData.Aggregations {
for _, s := range agg.Series {
for _, v := range s.Values {
values = append(values, v.Value)
}
}
}
return values
}
t.Fatal("no result for formula F")
return nil
}
first := formulaValues()
require.Equal(t, repeat(6, 10), first)
assert.Equal(t, first, formulaValues())
}
// lookbackMetricStmtBuilder stands in for the metrics statement builder. It
// widens the window the way the real builder does, so a runningDiff query
// fetches one step before the request.
type lookbackMetricStmtBuilder struct{}
func (b *lookbackMetricStmtBuilder) Build(_ context.Context, _ valuer.UUID, start, end uint64, _ qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], _ map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
return &qbtypes.Statement{Query: windowSQL(start, end, 0)}, nil
}
func TestRun_RunningDiffKeepsFirstIntervalOnEveryCachePath(t *testing.T) {
window := qbtypes.TimeRange{From: epochMs, To: epochMs + 3*minuteStepMs}
lookback := window.From - minuteStepMs
gauge := func(ts uint64, _ string) float64 { return 100 + float64((ts-lookback)/minuteStepMs)*10 }
store := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store.Mock().MatchExpectationsInOrder(false)
expectWindowQuery(store, lookback, window.To, 0, windowRows(lookback, window.To, []string{"a"}, gauge))
// The window then grows by one step at the end; only that step is fetched, with its own lookback.
grown := qbtypes.TimeRange{From: window.From, To: window.To + minuteStepMs}
expectWindowQuery(store, window.To-minuteStepMs, grown.To, 0, windowRows(window.To-minuteStepMs, grown.To, []string{"a"}, gauge))
q := newCachingQuerier(t, &windowLogStmtBuilder{}, store)
q.metricStmtBuilder = &lookbackMetricStmtBuilder{}
orgID := valuer.GenerateUUID()
spec := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
StepInterval: minuteStep(),
Aggregations: []qbtypes.MetricAggregation{{MetricName: "gauge", TimeAggregation: metrictypes.TimeAggregationAvg, SpaceAggregation: metrictypes.SpaceAggregationAvg}},
Functions: []qbtypes.Function{{Name: qbtypes.FunctionNameRunningDiff}},
}
diffs := func(window qbtypes.TimeRange) []float64 {
req := &qbtypes.QueryRangeRequest{
Start: window.From, End: window.To, RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{{Type: qbtypes.QueryTypeBuilder, Spec: spec}}},
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, qbtypes.QueryTypeBuilder, spec, window, req.RequestType, nil, builderConfig{})
resp, err := q.run(context.Background(), orgID, map[string]qbtypes.Query{"A": bq}, req, map[string]qbtypes.Step{"A": spec.StepInterval}, &qbtypes.QBEvent{}, nil)
require.NoError(t, err)
require.Len(t, resp.Data.Results, 1)
return pointsByService(resp.Data.Results[0].(*qbtypes.TimeSeriesData))["a"]
}
require.Equal(t, []float64{10, 10, 10}, diffs(window), "uncached")
assert.Equal(t, []float64{10, 10, 10}, diffs(window), "full cache hit")
assert.Equal(t, []float64{10, 10, 10, 10}, diffs(grown), "partial cache hit")
}
func TestCreateRangedQuery_PromQLReservedVariablesKeepRequestWindow(t *testing.T) {
store := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
engine := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: time.Minute}, store)
q := &querier{logger: instrumentationtest.New().Logger()}
window := qbtypes.TimeRange{From: epochMs, To: epochMs + 4*minuteStepMs}
original := newPromqlQuery(q.logger, engine, qbtypes.PromQuery{Name: "A", Query: "vector($start_timestamp)", Step: minuteStep()}, window, qbtypes.RequestTypeTimeSeries, nil)
gap := qbtypes.TimeRange{From: window.From + 2*minuteStepMs, To: window.To}
ranged := q.createRangedQuery(original, gap)
require.NotNil(t, ranged)
stmt, err := ranged.(*promqlQuery).Statement(context.Background())
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("vector(%d)", window.From/1000), stmt.Query)
assert.Equal(t, original.Fingerprint(), ranged.Fingerprint())
from, to := ranged.Window()
assert.Equal(t, gap, qbtypes.TimeRange{From: from, To: to})
}
func TestBuilderQueryFingerprint_IsStableWithSeveralVariables(t *testing.T) {
spec := logSpec(0)
spec.Filter = &qbtypes.Filter{Expression: "service.name = $svc AND deployment.environment = $env AND cloud.region = $region"}
variables := map[string]qbtypes.VariableItem{
"svc": {Value: "checkout"},
"env": {Value: "prod"},
"region": {Value: "eu-west-1"},
}
bq := newBuilderQuery(instrumentationtest.New().Logger(), nil, valuer.GenerateUUID(), &windowLogStmtBuilder{}, qbtypes.QueryTypeBuilder, spec, qbtypes.TimeRange{From: epochMs, To: epochMs + minuteStepMs}, qbtypes.RequestTypeTimeSeries, variables, builderConfig{})
seen := map[string]struct{}{}
for range 50 {
seen[bq.Fingerprint()] = struct{}{}
}
assert.Len(t, seen, 1)
}
func TestBuilderQueryFingerprint_DistinguishesVariableValues(t *testing.T) {
spec := logSpec(0)
spec.Filter = &qbtypes.Filter{Expression: "service.name IN $svc"}
key := func(value any) string {
bq := newBuilderQuery(instrumentationtest.New().Logger(), nil, valuer.GenerateUUID(), &windowLogStmtBuilder{}, qbtypes.QueryTypeBuilder, spec, qbtypes.TimeRange{From: epochMs, To: epochMs + minuteStepMs}, qbtypes.RequestTypeTimeSeries, map[string]qbtypes.VariableItem{"svc": {Value: value}}, builderConfig{})
return bq.Fingerprint()
}
assert.NotEqual(t, key([]any{"a b"}), key([]any{"a", "b"}))
assert.NotEqual(t, key(1.0), key("1"))
}
func TestBuilderQueryFingerprint_LimitedGroupByIncludesTheWindow(t *testing.T) {
key := func(limit int, window qbtypes.TimeRange) string {
return newBuilderQuery(instrumentationtest.New().Logger(), nil, valuer.GenerateUUID(), &windowLogStmtBuilder{}, qbtypes.QueryTypeBuilder, logSpec(limit), window, qbtypes.RequestTypeTimeSeries, nil, builderConfig{}).Fingerprint()
}
first := qbtypes.TimeRange{From: epochMs, To: epochMs + 10*minuteStepMs}
second := qbtypes.TimeRange{From: epochMs, To: epochMs + 20*minuteStepMs}
assert.NotEqual(t, key(2, first), key(2, second))
assert.Equal(t, key(0, first), key(0, second))
}
func TestCreateRangedQuery_MetricsPieceReadsTheTablesOfTheRequest(t *testing.T) {
q := &querier{logger: instrumentationtest.New().Logger(), metricStmtBuilder: &lookbackMetricStmtBuilder{}}
spec := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Minute},
Aggregations: []qbtypes.MetricAggregation{{MetricName: "gauge", Type: metrictypes.GaugeType, TimeAggregation: metrictypes.TimeAggregationAvg, SpaceAggregation: metrictypes.SpaceAggregationAvg}},
}
request := qbtypes.TimeRange{From: epochMs, To: epochMs + 3*24*60*minuteStepMs}
bq := newBuilderQuery(q.logger, nil, valuer.GenerateUUID(), q.metricStmtBuilder, qbtypes.QueryTypeBuilder, spec, request, qbtypes.RequestTypeTimeSeries, nil, builderConfig{})
gap := qbtypes.TimeRange{From: request.To - 60*minuteStepMs, To: request.To}
ranged := q.createRangedQuery(bq, gap)
require.NotNil(t, ranged)
want := metricstelemetryschema.TableHintsForWindow(request.From, request.To, spec.Aggregations[0].Type, spec.Aggregations[0].TimeAggregation, false, nil)
require.NotNil(t, want)
got := ranged.(*builderQuery[qbtypes.MetricAggregation]).spec.Aggregations[0].TableHints
assert.Equal(t, want, got)
assert.Nil(t, bq.spec.Aggregations[0].TableHints, "the original query is left as it is")
}
func TestCreateRangedQuery_MeterPieceReadsTheTableOfTheRequest(t *testing.T) {
q := &querier{logger: instrumentationtest.New().Logger(), meterStmtBuilder: &lookbackMetricStmtBuilder{}}
spec := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Source: telemetrytypes.SourceMeter,
StepInterval: qbtypes.Step{Duration: 24 * time.Hour},
Aggregations: []qbtypes.MetricAggregation{{MetricName: "meter", Type: metrictypes.SumType, TimeAggregation: metrictypes.TimeAggregationSum, SpaceAggregation: metrictypes.SpaceAggregationSum}},
}
request := qbtypes.TimeRange{From: epochMs, To: epochMs + 60*24*60*minuteStepMs}
bq := newBuilderQuery(q.logger, nil, valuer.GenerateUUID(), q.meterStmtBuilder, qbtypes.QueryTypeBuilder, spec, request, qbtypes.RequestTypeTimeSeries, nil, builderConfig{})
gap := qbtypes.TimeRange{From: request.To - 24*60*minuteStepMs, To: request.To}
ranged := q.createRangedQuery(bq, gap)
require.NotNil(t, ranged)
got := ranged.(*builderQuery[qbtypes.MetricAggregation]).spec.Aggregations[0].TableHints
require.NotNil(t, got)
assert.Equal(t, metertelemetryschema.SamplesAgg1dTableName, got.SamplesTableName)
assert.Equal(t, metertelemetryschema.SamplesTableName, metertelemetryschema.WhichSamplesTableToUse(gap.From, gap.To, spec.Aggregations[0].Type, spec.Aggregations[0].TimeAggregation, nil), "the piece alone would read the raw table")
}
func TestTrimsHeatmapAxis_OnlyForAnAxisComputedFromTheData(t *testing.T) {
metricHeatmap := func(bucketing *qbtypes.HeatmapBucketing) qbtypes.Query {
spec := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
StepInterval: minuteStep(),
Aggregations: []qbtypes.MetricAggregation{{MetricName: "m", HeatmapBucketing: bucketing}},
}
return newBuilderQuery(instrumentationtest.New().Logger(), nil, valuer.GenerateUUID(), &lookbackMetricStmtBuilder{}, qbtypes.QueryTypeBuilder, spec, qbtypes.TimeRange{From: epochMs, To: epochMs + minuteStepMs}, qbtypes.RequestTypeHeatmap, nil, builderConfig{})
}
assert.True(t, trimsHeatmapAxis(metricHeatmap(&qbtypes.HeatmapBucketing{Kind: qbtypes.BucketsKindLog})), "a gauge heatmap buckets the served values")
assert.False(t, trimsHeatmapAxis(metricHeatmap(nil)), "a histogram reports its own le buckets")
assert.False(t, trimsHeatmapAxis(newPromqlQuery(instrumentationtest.New().Logger(), nil, qbtypes.PromQuery{Query: "up", Step: minuteStep()}, qbtypes.TimeRange{From: epochMs, To: epochMs + minuteStepMs}, qbtypes.RequestTypeHeatmap, nil)))
}
func TestBuildQueries_PromQLWindowIsMovedToTheStepGridUnlessAskedNotTo(t *testing.T) {
q := &querier{logger: instrumentationtest.New().Logger()}
req := &qbtypes.QueryRangeRequest{
Start: epochMs + 17_000, End: epochMs + 10*minuteStepMs + 45_000, RequestType: qbtypes.RequestTypeTimeSeries,
CompositeQuery: qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{{Type: qbtypes.QueryTypePromQL, Spec: qbtypes.PromQuery{Name: "A", Query: "up", Step: minuteStep()}}}},
}
queries, _, err := q.buildQueries(valuer.GenerateUUID(), req, nil, nil, &qbtypes.QBEvent{})
require.NoError(t, err)
assert.Equal(t, qbtypes.TimeRange{From: epochMs, To: epochMs + 10*minuteStepMs}, queries["A"].(*promqlQuery).tr)
assert.NotEmpty(t, queries["A"].Fingerprint())
req.NoStepAlignment = true
queries, _, err = q.buildQueries(valuer.GenerateUUID(), req, nil, nil, &qbtypes.QBEvent{})
require.NoError(t, err)
assert.Equal(t, qbtypes.TimeRange{From: req.Start, To: req.End}, queries["A"].(*promqlQuery).tr)
assert.Empty(t, queries["A"].Fingerprint(), "a window kept off the grid is not cached")
}
func TestPromQLFingerprint_StartOrEndModifierIsNotCached(t *testing.T) {
fingerprint := func(expr string) string {
return newPromqlQuery(instrumentationtest.New().Logger(), nil, qbtypes.PromQuery{Query: expr, Step: minuteStep()}, qbtypes.TimeRange{From: epochMs, To: epochMs + 10*minuteStepMs}, qbtypes.RequestTypeTimeSeries, nil).Fingerprint()
}
assert.NotEmpty(t, fingerprint("sum(rate(up[5m]))"))
assert.NotEmpty(t, fingerprint("up @ 1672531200"), "a fixed instant means the same in every piece")
assert.Empty(t, fingerprint("up @ end()"))
assert.Empty(t, fingerprint("sum(up @ start())"))
assert.Empty(t, fingerprint("max_over_time(up[5m:1m] @ end())"))
}

View File

@@ -78,7 +78,8 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0),
},
NoCache: true,
NoCache: true,
NoStepAlignment: true,
}
req.CompositeQuery.Queries = make([]qbtypes.QueryEnvelope, len(r.Condition().CompositeQuery.Queries))
copy(req.CompositeQuery.Queries, r.Condition().CompositeQuery.Queries)

View File

@@ -176,6 +176,31 @@ func MinAllowedStepIntervalForMetric(start, end uint64) uint64 {
return minAllowed
}
// RateLookbackMs is how far before a bucket the previous sample of a
// cumulative series may lie for rate and increase to use it. The bound makes
// the value of a bucket depend only on the samples within the lookback, not
// on where the statement window starts, so a window served in pieces agrees
// with one statement over the whole window. Five minutes is the Prometheus
// lookback; a step longer than that keeps one step.
func RateLookbackMs(stepMs uint64) uint64 {
return max(stepMs, uint64((5 * time.Minute).Milliseconds()))
}
// MetricRateLookbackMs is the lookback the statement of mq reads before its
// window, or zero when mq computes no rate. A histogram percentile or count
// computes a rate or increase over its buckets whatever time aggregation the
// query names.
func MetricRateLookbackMs(stepMs uint64, mq qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) uint64 {
agg := mq.Aggregations[0]
usesRate := agg.TimeAggregation == metrictypes.TimeAggregationRate ||
agg.TimeAggregation == metrictypes.TimeAggregationIncrease ||
agg.Type == metrictypes.HistogramType
if !usesRate || agg.Temporality == metrictypes.Delta {
return 0
}
return RateLookbackMs(stepMs)
}
func AdjustedMetricTimeRange(start, end, step uint64, mq qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]) (uint64, uint64) {
// align the start to the step interval
start = start - (start % (step * 1000))
@@ -188,10 +213,7 @@ func AdjustedMetricTimeRange(start, end, step uint64, mq qbtypes.QueryBuilderQue
break
}
}
if (mq.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate || mq.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationIncrease) &&
mq.Aggregations[0].Temporality != metrictypes.Delta {
start -= step * 1000
}
start -= MetricRateLookbackMs(step*1000, mq)
if hasRunningDiff {
start -= step * 1000
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
@@ -61,7 +62,10 @@ func TestNewHandlers(t *testing.T) {
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
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)
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)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -45,7 +45,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tag"
@@ -124,9 +123,10 @@ func NewModules(
fl flagger.Flagger,
tagModule tag.Module,
metricReductionRule metricreductionrule.Module,
spanMapper spanmapper.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard)
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard, spanMapper)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
@@ -162,8 +162,8 @@ func NewModules(
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
Tag: tagModule,
}
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -68,7 +69,11 @@ func TestNewModules(t *testing.T) {
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
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())
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, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), spanMapperModule)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

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

View File

@@ -36,6 +36,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
@@ -526,7 +527,15 @@ func New(
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
// Initialize all modules
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)
// The default mapping group registry is parsed here so a malformed embedded
// definition fails startup instead of a request.
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
if err != nil {
return nil, err
}
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)
// 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")
@@ -596,6 +605,7 @@ func New(
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
factory.NewNamedService(factory.MustNewName("systemdashboard"), impldashboard.NewService(providerSettings, dashboard, orgGetter)),
factory.NewNamedService(factory.MustNewName("spanmappergroup"), implspanmapper.NewService(providerSettings, spanMapperModule, orgGetter)),
)
if err != nil {
return nil, err

View File

@@ -0,0 +1,93 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addSpanMapperOrigin struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddSpanMapperOriginFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_span_mapper_origin"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSpanMapperOrigin{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (migration *addSpanMapperOrigin) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up adds the ownership columns that let SigNoz ship default mapping groups
// alongside user ones.
func (migration *addSpanMapperOrigin) Up(ctx context.Context, db *bun.DB) error {
// span_mapper references span_mapper_group and both have foreign keys, so
// enforcement must be off for the SQLite recreate-table fallback.
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
groupTable, groupUniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("span_mapper_group"))
if err != nil {
return err
}
sqls := migration.sqlschema.Operator().AddColumn(groupTable, groupUniqueConstraints, &sqlschema.Column{
Name: sqlschema.ColumnName("origin"),
DataType: sqlschema.DataTypeText,
Nullable: false,
Default: "'user'",
}, "user")
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(groupTable, groupUniqueConstraints, &sqlschema.Column{
Name: sqlschema.ColumnName("version"),
DataType: sqlschema.DataTypeBigInt,
Nullable: false,
Default: "0",
}, 0)...)
mapperTable, mapperUniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("span_mapper"))
if err != nil {
return err
}
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(mapperTable, mapperUniqueConstraints, &sqlschema.Column{
Name: sqlschema.ColumnName("origin"),
DataType: sqlschema.DataTypeText,
Nullable: false,
Default: "'user'",
}, "user")...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return migration.sqlschema.ToggleFKEnforcement(ctx, db, true)
}
func (migration *addSpanMapperOrigin) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -364,7 +364,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
for i, g := range query.GroupBy {
wrapped.SelectMore(sqlbuilder.Escape(metricsstatementbuilder.GroupByColumnAlias(i, g.Name)))
}
wrapped.SelectMore(fmt.Sprintf("%s AS per_series_value", metricsstatementbuilder.RateTmpl))
wrapped.SelectMore(fmt.Sprintf("%s AS per_series_value", metricsstatementbuilder.RateExpr(querybuilder.RateLookbackMs(uint64(query.StepInterval.Milliseconds()))/1000)))
wrapped.From(fmt.Sprintf("(%s) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)", innerQuery))
q, args := wrapped.BuildWithFlavor(sqlbuilder.ClickHouse, innerArgs...)
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, nil
@@ -375,7 +375,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
for i, g := range query.GroupBy {
wrapped.SelectMore(sqlbuilder.Escape(metricsstatementbuilder.GroupByColumnAlias(i, g.Name)))
}
wrapped.SelectMore(fmt.Sprintf("%s AS per_series_value", metricsstatementbuilder.IncreaseTmpl))
wrapped.SelectMore(fmt.Sprintf("%s AS per_series_value", metricsstatementbuilder.IncreaseExpr(querybuilder.RateLookbackMs(uint64(query.StepInterval.Milliseconds()))/1000)))
wrapped.From(fmt.Sprintf("(%s) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)", innerQuery))
q, args := wrapped.BuildWithFlavor(sqlbuilder.ClickHouse, innerArgs...)
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, nil

View File

@@ -56,7 +56,7 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(86400)) AS ts, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_meter.distributed_samples AS points WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? AND JSONExtractString(labels, 'service.name') = ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 86400), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(86400)) AS ts, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_meter.distributed_samples AS points WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? AND JSONExtractString(labels, 'service.name') = ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747785600000), uint64(1747983420000), "cartservice", "cumulative", 0},
},
expectedErr: nil,

View File

@@ -0,0 +1,33 @@
package metricsstatementbuilder
import "fmt"
// noPredecessor is true for the first bucket of a series in the statement
// and for a bucket whose previous bucket lies further back than the lookback.
// Both get nan: a value computed against a sample outside the lookback would
// depend on where the statement window starts.
func noPredecessor(lookbackSec uint64) string {
return fmt.Sprintf("(row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > %d)", lookbackSec)
}
// RateExpr is the per-bucket rate of a cumulative series: the increase since
// the previous bucket divided by the seconds between them; a reset counts the
// bucket's own value.
func RateExpr(lookbackSec uint64) string {
return fmt.Sprintf(`multiIf(%s, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window))`, noPredecessor(lookbackSec))
}
// IncreaseExpr is the per-bucket increase of a cumulative series.
func IncreaseExpr(lookbackSec uint64) string {
return fmt.Sprintf(`multiIf(%s, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window)`, noPredecessor(lookbackSec))
}
func rateMultiTemporalityExpr(lookbackSec uint64, delta, cumulative string) string {
return fmt.Sprintf(`IF(LOWER(temporality) LIKE LOWER('delta'), %s, multiIf(%s, nan, (%s - lagInFrame(%s, 1) OVER rate_window) < 0, %s / (ts - lagInFrame(ts, 1) OVER rate_window), (%s - lagInFrame(%s, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window))) AS per_series_value`,
delta, noPredecessor(lookbackSec), cumulative, cumulative, cumulative, cumulative, cumulative)
}
func increaseMultiTemporalityExpr(lookbackSec uint64, delta, cumulative string) string {
return fmt.Sprintf(`IF(LOWER(temporality) LIKE LOWER('delta'), %s, multiIf(%s, nan, (%s - lagInFrame(%s, 1) OVER rate_window) < 0, %s, (%s - lagInFrame(%s, 1) OVER rate_window))) AS per_series_value`,
delta, noPredecessor(lookbackSec), cumulative, cumulative, cumulative, cumulative, cumulative)
}

View File

@@ -90,23 +90,23 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "counter_sum_rate",
query: reducedQuery("test.metric.sum", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationRate, metrictypes.SpaceAggregationSum),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(`sum`) / 300 AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746997200000), uint64(1747172760000), "test.metric.sum", uint64(1746999600000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1746999900)), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(`sum`) / 300 AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
Args: []any{"test.metric.sum", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric.sum", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.sum", uint64(1746997200000), uint64(1747172760000), "test.metric.sum", uint64(1746999900000), uint64(1747172760000)},
},
},
{
name: "counter_avg_increase",
query: reducedQuery("test.metric", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationIncrease, metrictypes.SpaceAggregationAvg),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT points.reduced_fingerprint AS fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(`sum`) AS per_series_value, avg(`count_series`) AS per_series_weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746997200000), uint64(1747172760000), "test.metric", uint64(1746999600000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1746999900)), __spatial_aggregation_cte AS (SELECT ts, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) UNION ALL SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT points.reduced_fingerprint AS fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, sum(`sum`) AS per_series_value, avg(`count_series`) AS per_series_weight FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, sum(per_series_value) / sum(per_series_weight) AS value FROM __temporal_aggregation_cte GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts) ORDER BY ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric", uint64(1746999600000), uint64(1747172760000), 0, "test.metric", uint64(1746997200000), uint64(1747172760000), "test.metric", uint64(1746999900000), uint64(1747172760000)},
},
},
{
name: "counter_min_omitted",
query: reducedQuery("test.metric", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationRate, metrictypes.SpaceAggregationMin),
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts",
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1746999900)), __spatial_aggregation_cte AS (SELECT ts, min(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric", uint64(1746999600000), uint64(1747172760000), 0},
},
},
@@ -114,7 +114,7 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "counter_max_omitted",
query: reducedQuery("test.metric", metrictypes.SumType, metrictypes.Cumulative, metrictypes.TimeAggregationRate, metrictypes.SpaceAggregationMax),
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts",
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1746999900)), __spatial_aggregation_cte AS (SELECT ts, max(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts) SELECT * FROM __spatial_aggregation_cte ORDER BY ts",
Args: []any{"test.metric", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric", uint64(1746999600000), uint64(1747172760000), 0},
},
},
@@ -122,16 +122,16 @@ func TestReducedStatementBuilder(t *testing.T) {
name: "histogram_p99",
query: reducedQuery("test.metric.bucket", metrictypes.HistogramType, metrictypes.Cumulative, metrictypes.TimeAggregationUnspecified, metrictypes.SpaceAggregationPercentile99),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(`sum`) / 300 AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746997200000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `le`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1746999900)), __spatial_aggregation_cte AS (SELECT ts, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `le`, sum(`sum`) / 300 AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `le`) SELECT ts, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY ts ORDER BY ts) ORDER BY ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric.bucket", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746997200000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
},
},
{
name: "histogram_p99_group_by",
query: withGroupBy(reducedQuery("test.metric.bucket", metrictypes.HistogramType, metrictypes.Cumulative, metrictypes.TimeAggregationUnspecified, metrictypes.SpaceAggregationPercentile99), "service.name"),
expected: qbtypes.Statement{
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(`sum`) / 300 AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric.bucket", uint64(1746999900000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746997200000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
Query: "SELECT * FROM (WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(max) AS per_series_value FROM signoz_metrics.distributed_samples_v4_agg_5m AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_1day WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1746999900)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts) UNION ALL SELECT * FROM (WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(300)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(`sum`) / 300 AS value FROM signoz_metrics.distributed_samples_v4_reduced_sum_60s AS points FINAL INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_reduced WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.reduced_fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.990) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts SETTINGS do_not_merge_across_partitions_select_final = 1, optimize_move_to_prewhere_if_final = 1",
Args: []any{"test.metric.bucket", uint64(1746921600000), uint64(1747172760000), "cumulative", "test.metric.bucket", uint64(1746999600000), uint64(1747172760000), 0, "test.metric.bucket", uint64(1746997200000), uint64(1747172760000), "test.metric.bucket", uint64(1746999900000), uint64(1747172760000)},
},
},
{

View File

@@ -8,7 +8,6 @@ import (
"slices"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
@@ -24,17 +23,7 @@ import (
"github.com/huandu/go-sqlbuilder"
)
const (
RateTmpl = `multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window))`
IncreaseTmpl = `multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window)`
RateMultiTemporalityTmpl = `IF(LOWER(temporality) LIKE LOWER('delta'), %s, multiIf(row_number() OVER rate_window = 1, nan, (%s - lagInFrame(%s, 1) OVER rate_window) < 0, %s / (ts - lagInFrame(ts, 1) OVER rate_window), (%s - lagInFrame(%s, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window))) AS per_series_value`
IncreaseMultiTemporality = `IF(LOWER(temporality) LIKE LOWER('delta'), %s, multiIf(row_number() OVER rate_window = 1, nan, (%s - lagInFrame(%s, 1) OVER rate_window) < 0, %s, (%s - lagInFrame(%s, 1) OVER rate_window))) AS per_series_value`
OthersMultiTemporality = `IF(LOWER(temporality) LIKE LOWER('delta'), %s, %s) AS per_series_value`
)
const OthersMultiTemporality = `IF(LOWER(temporality) LIKE LOWER('delta'), %s, %s) AS per_series_value`
type StatementBuilder struct {
logger *slog.Logger
@@ -154,9 +143,7 @@ func (b *StatementBuilder) buildPipelineStatement(
// samples_v4/agg (unioned with the reduced tables) otherwise. The buffer is
// shaped exactly like samples_v4 / time_series_v4, so once the table names are
// chosen the rest of the pipeline is unchanged.
useBuffer := agg.Reduced &&
end-start < metricstelemetryschema.OneDayInMilliseconds &&
start >= uint64(time.Now().UnixMilli())-metricstelemetryschema.OneDayInMilliseconds
useBuffer := metricstelemetryschema.UsesBuffer(start, end, agg.Reduced, agg.TableHints)
samplesTable, _ := metricstelemetryschema.WhichSamplesTableToUse(start, end, agg.Type, agg.TimeAggregation, useBuffer, agg.TableHints)
tsStart, tsEnd, _, tsTable := metricstelemetryschema.WhichTSTableToUse(start, end, useBuffer, agg.TableHints)
@@ -199,6 +186,9 @@ func (b *StatementBuilder) buildPipelineStatement(
if agg.Reduced && !useBuffer {
var tsCTE string
var tsArgs []any
// The reduced rows hold per-bucket values that need no predecessor,
// so this half starts where the answer starts.
start := start + querybuilder.MetricRateLookbackMs(uint64(query.StepInterval.Milliseconds()), cteQuery)
// time series rows are written on hour boundaries
tsStart := start - (start % metricstelemetryschema.OneHourInMilliseconds)
if tsCTE, tsArgs, err = b.buildReducedTimeSeriesCTE(ctx, orgID, tsStart, end, cteQuery, keys, variables); err != nil {
@@ -652,31 +642,28 @@ func (b *StatementBuilder) buildTemporalAggCumulativeOrUnspecified(
innerQuery, innerArgs := baseSb.BuildWithFlavor(sqlbuilder.ClickHouse, timeSeriesCTEArgs...)
lookbackSec := querybuilder.RateLookbackMs(uint64(stepSec)*1000) / 1000
var expr string
switch query.Aggregations[0].TimeAggregation {
case metrictypes.TimeAggregationRate:
wrapped := sqlbuilder.NewSelectBuilder()
wrapped.Select("ts")
for i, g := range query.GroupBy {
wrapped.SelectMore(sqlbuilder.Escape(GroupByColumnAlias(i, g.Name)))
}
wrapped.SelectMore(fmt.Sprintf("%s AS per_series_value", RateTmpl))
wrapped.From(fmt.Sprintf("(%s) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)", sqlbuilder.Escape(innerQuery)))
q, args := wrapped.BuildWithFlavor(sqlbuilder.ClickHouse, innerArgs...)
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, nil
expr = RateExpr(lookbackSec)
case metrictypes.TimeAggregationIncrease:
wrapped := sqlbuilder.NewSelectBuilder()
wrapped.Select("ts")
for i, g := range query.GroupBy {
wrapped.SelectMore(sqlbuilder.Escape(GroupByColumnAlias(i, g.Name)))
}
wrapped.SelectMore(fmt.Sprintf("%s AS per_series_value", IncreaseTmpl))
wrapped.From(fmt.Sprintf("(%s) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)", sqlbuilder.Escape(innerQuery)))
q, args := wrapped.BuildWithFlavor(sqlbuilder.ClickHouse, innerArgs...)
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, nil
expr = IncreaseExpr(lookbackSec)
default:
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", innerQuery), innerArgs, nil
}
wrapped := sqlbuilder.NewSelectBuilder()
wrapped.Select("ts")
for i, g := range query.GroupBy {
wrapped.SelectMore(sqlbuilder.Escape(GroupByColumnAlias(i, g.Name)))
}
wrapped.SelectMore(fmt.Sprintf("%s AS per_series_value", expr))
wrapped.From(fmt.Sprintf("(%s) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)", sqlbuilder.Escape(innerQuery)))
q, args := wrapped.BuildWithFlavor(sqlbuilder.ClickHouse, innerArgs...)
// The lookback rows exist to give the first buckets a predecessor; they
// are not part of the answer.
q = fmt.Sprintf("SELECT * FROM (%s) WHERE ts >= toDateTime(%d)", q, (start+querybuilder.RateLookbackMs(uint64(stepSec)*1000))/1000)
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", q), args, nil
}
func (b *StatementBuilder) buildTemporalAggForMultipleTemporalities(
@@ -710,21 +697,15 @@ func (b *StatementBuilder) buildTemporalAggForMultipleTemporalities(
aggForDeltaTemporality = fmt.Sprintf("%s/%d", aggForDeltaTemporality, stepSec)
}
lookbackSec := querybuilder.RateLookbackMs(uint64(stepSec)*1000) / 1000
usesLookback := false
switch query.Aggregations[0].TimeAggregation {
case metrictypes.TimeAggregationRate:
rateExpr := fmt.Sprintf(RateMultiTemporalityTmpl,
aggForDeltaTemporality,
aggForCumulativeTemporality, aggForCumulativeTemporality, aggForCumulativeTemporality,
aggForCumulativeTemporality, aggForCumulativeTemporality,
)
sb.SelectMore(rateExpr)
sb.SelectMore(rateMultiTemporalityExpr(lookbackSec, aggForDeltaTemporality, aggForCumulativeTemporality))
usesLookback = true
case metrictypes.TimeAggregationIncrease:
increaseExpr := fmt.Sprintf(IncreaseMultiTemporality,
aggForDeltaTemporality,
aggForCumulativeTemporality, aggForCumulativeTemporality, aggForCumulativeTemporality,
aggForCumulativeTemporality, aggForCumulativeTemporality,
)
sb.SelectMore(increaseExpr)
sb.SelectMore(increaseMultiTemporalityExpr(lookbackSec, aggForDeltaTemporality, aggForCumulativeTemporality))
usesLookback = true
default:
expr := fmt.Sprintf(OthersMultiTemporality, aggForDeltaTemporality, aggForCumulativeTemporality)
sb.SelectMore(expr)
@@ -741,6 +722,9 @@ func (b *StatementBuilder) buildTemporalAggForMultipleTemporalities(
sb.GroupBy(GroupByAliases(query.GroupBy)...)
queryWithoutWindow, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse, timeSeriesCTEArgs...)
queryWithWindowAndOrder := queryWithoutWindow + " WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint ASC, ts ASC) ORDER BY ts"
if usesLookback {
queryWithWindowAndOrder = fmt.Sprintf("SELECT * FROM (%s) WHERE ts >= toDateTime(%d)", queryWithWindowAndOrder, (start+querybuilder.RateLookbackMs(uint64(stepSec)*1000))/1000)
}
return fmt.Sprintf("__temporal_aggregation_cte AS (%s)", queryWithWindowAndOrder), args, nil
}

View File

@@ -55,8 +55,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'service.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "cartservice", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'service.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1747947390)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "cartservice", "signoz_calls_total", uint64(1747947090000), uint64(1747983420000), 0},
},
expectedErr: nil,
},
@@ -88,8 +88,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND (match(JSONExtractString(labels, 'materialized.key.name'), ?) OR JSONExtractString(labels, 'service.name') = ?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "cartservice", "cartservice", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND (match(JSONExtractString(labels, 'materialized.key.name'), ?) OR JSONExtractString(labels, 'service.name') = ?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1747947390)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "cartservice", "cartservice", "signoz_calls_total", uint64(1747947090000), uint64(1747983420000), 0},
},
expectedErr: nil,
},
@@ -440,8 +440,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, lagInFrame(toFloat64(le), 1, toFloat64('-Inf')) OVER __heatmap_window AS __bucket_min, toFloat64(le) AS __bucket_max, greatest(value - lagInFrame(value, 1, 0) OVER __heatmap_window, 0) AS __result_0 FROM __spatial_aggregation_cte WINDOW __heatmap_window AS (PARTITION BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY toFloat64(le)) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, toFloat64(le)",
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", "http_server_duration_bucket", uint64(1747947300000), uint64(1747983420000), 0},
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1747947360)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, lagInFrame(toFloat64(le), 1, toFloat64('-Inf')) OVER __heatmap_window AS __bucket_min, toFloat64(le) AS __bucket_max, greatest(value - lagInFrame(value, 1, 0) OVER __heatmap_window, 0) AS __result_0 FROM __spatial_aggregation_cte WINDOW __heatmap_window AS (PARTITION BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY toFloat64(le)) ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, toFloat64(le)",
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", "http_server_duration_bucket", uint64(1747947060000), uint64(1747983420000), 0},
},
expectedErr: nil,
},
@@ -474,8 +474,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(value <= 0, toFloat64('-Inf'), value <= 2.3283064365386963e-10, toFloat64(0), value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(value) * 16) - 1) / 16)) AS __bucket_min, multiIf(value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, __bucket_max",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "signoz_calls_total", uint64(1747947300000), uint64(1747983420000), 0},
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1747947360)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(value <= 0, toFloat64('-Inf'), value <= 2.3283064365386963e-10, toFloat64(0), value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(value) * 16) - 1) / 16)) AS __bucket_min, multiIf(value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, __bucket_max",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "signoz_calls_total", uint64(1747947060000), uint64(1747983420000), 0},
},
expectedErr: nil,
},
@@ -537,8 +537,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", "http_server_duration_bucket", uint64(1747947360000), uint64(1747983420000), 0},
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, `le`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name`, JSONExtractString(labels, 'le') AS `le` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`, `le`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name`, `le` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1747947390)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, `le`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`, `le`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.950) AS value FROM __spatial_aggregation_cte GROUP BY `__GROUP_BY_KEY_0_service.name`, ts ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"http_server_duration_bucket", uint64(1747936800000), uint64(1747983420000), "cumulative", "http_server_duration_bucket", uint64(1747947090000), uint64(1747983420000), 0},
},
expectedErr: nil,
},
@@ -569,8 +569,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `__GROUP_BY_KEY_0_k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_k8s.statefulset.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "my-statefulset", "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'k8s.statefulset.name') AS `__GROUP_BY_KEY_0_k8s.statefulset.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'k8s.statefulset.name') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_k8s.statefulset.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_k8s.statefulset.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1747947390)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_k8s.statefulset.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_k8s.statefulset.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "my-statefulset", "signoz_calls_total", uint64(1747947090000), uint64(1747983420000), 0},
Warnings: []string{"key `k8s.statefulset.name` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos."},
},
expectedErr: nil,
@@ -602,8 +602,8 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", true, "signoz_calls_total", uint64(1747947360000), uint64(1747983420000), 0},
Query: "WITH __temporal_aggregation_cte AS (SELECT * FROM (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf((row_number() OVER rate_window = 1 OR (ts - lagInFrame(ts, 1) OVER rate_window) > 300), nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value / (ts - lagInFrame(ts, 1) OVER rate_window), (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) / (ts - lagInFrame(ts, 1) OVER rate_window)) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND accurateCastOrNull(JSONExtractString(labels, 'success'), 'Bool') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)) WHERE ts >= toDateTime(1747947390)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", true, "signoz_calls_total", uint64(1747947090000), uint64(1747983420000), 0},
},
expectedErr: nil,
},

View File

@@ -0,0 +1,13 @@
package metertelemetryschema
import "github.com/SigNoz/signoz/pkg/types/metrictypes"
// TableHintsForWindow pins the samples table the builder picks for
// [start, end), so a statement over a piece of that window reads the same
// table.
func TableHintsForWindow(start, end uint64, metricType metrictypes.Type, timeAggregation metrictypes.TimeAggregation, tableHints *metrictypes.MetricTableHints) *metrictypes.MetricTableHints {
if tableHints != nil {
return tableHints
}
return &metrictypes.MetricTableHints{SamplesTableName: WhichSamplesTableToUse(start, end, metricType, timeAggregation, nil)}
}

Some files were not shown because too many files have changed in this diff Show More