Compare commits

..

11 Commits

Author SHA1 Message Date
SagarRajput-7
1cdd2ec001 feat: multiple style and functionality fixes 2026-03-10 02:07:53 +05:30
SagarRajput-7
0bac99742e feat: new service_account page with crud and listing 2026-03-10 02:07:53 +05:30
Karan Balani
6f8da2edeb feat: deprecate user invite table and add user status lifecycle (#10445)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* feat: deprecate user invite table

* fix: handle soft deleted users flow

* fix: handle edge cases for authentication and reset password flow

* feat: integration tests with fixes for new flow

* fix: array for grants

* fix: edge cases for reset token and context api

* chore: remove all code related to old invite flow

* fix: openapi specs

* fix: integration tests and minor naming change

* fix: integration tests fmtlint

* feat: improve invitation email template

* fix: role tests

* fix: context api

* fix: openapi frontend

* chore: rename countbyorgid to activecountbyorgid

* fix: a deleted user cannot recycled, creating a new one

* feat: migrate existing invites to user as pending invite status

* fix: error from GetUsersByEmailAndOrgID

* feat: add backward compatibility to existing apis using new invite flow

* chore: change ordering of apis in server

* chore: change ordering of apis in server

* fix: filter active users in role and org id check

* fix: check deleted user in reset password flow

* chore: address some review comments, add back countbyorgid method

* chore: move to bulk inserts for migrating existing invites

* fix: wrap funcs to transactions, and fix openapi specs

* fix: move reset link method to types, also move authz grants outside transation

* fix: transaction issues

* feat: helper method ErrIfDeleted for user

* fix: error code for errifdeleted in user

* fix: soft delete store method

* fix: password authn tests also add old invite flow test

* fix: callbackauthn tests

* fix: remove extra oidc tests

* fix: callback authn tests oidc

* chore: address review comments and optimise bulk invite api

* fix: use db ctx in various places

* fix: fix duplicate email invite issue and add partial invite

* fix: openapi specs

* fix: errifpending

* fix: user status persistence

* fix: edge cases

* chore: add tests for partial index too

* feat: use composite unique index on users table instead of partial one

* chore: move duplicate email check to unmarshaljson and query user again in accept invite

* fix: make 068 migratin idempotent

* chore: remove unused emails var

* chore: add a temp filter to show only active users in frontend until next frontend fix

* chore: remove one check from register flow testing until temp code is removed

* chore: remove commented code from tests

* chore: address frontend review comments

* chore: address frontend review comments
2026-03-09 18:16:04 +00:00
Vikrant Gupta
ec543eb89c feat(authz): register role and assignee relationships (#10538) 2026-03-09 17:03:27 +00:00
Srikanth Chekuri
48acc297a8 chore: add initial version of query range design principles doc (#10415)
Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-03-09 17:01:13 +00:00
Vinicius Lourenço
1430e5fa99 perf(grid-card): set cache time zero when auto-refresh is enabled (#10225) 2026-03-09 14:54:14 +00:00
Ashwin Bhatkal
1f3f611e9a chore: remove tsc2 check from jsci (#10527)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-03-09 19:26:36 +05:30
Aditya Singh
3c59f45a2f feat: set list height in trace details page for filters (#10534) 2026-03-09 11:23:46 +00:00
Abhi kumar
6fb92880cc feat: legend auto generation based on group by (#10529)
* feat: legend auto generation based on group by

* chore: removed redundent check from util
2026-03-09 11:22:28 +00:00
SagarRajput-7
089a8ee323 feat: removed members and invited user tables from sso page (#10517)
* feat: added components and styles

* feat: added password modal and other functionality

* feat: removed members and invited user tables from sso page

* feat: refactored and addressed the comments

* feat: rebase with main fix
2026-03-09 11:16:23 +00:00
Ashwin Bhatkal
dab7f506f7 fix(frontend): refresh generated api on 401 errors (#10532) 2026-03-09 11:16:13 +00:00
142 changed files with 6146 additions and 3543 deletions

View File

@@ -13,23 +13,6 @@ on:
jobs:
tsc:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install
run: cd frontend && yarn install
- name: tsc
run: cd frontend && yarn tsc
tsc2:
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||

View File

@@ -1,4 +1,4 @@
FROM node:18-bullseye AS build
FROM node:22-bookworm AS build
WORKDIR /opt/
COPY ./frontend/ ./

View File

@@ -2108,6 +2108,15 @@ components:
token:
type: string
type: object
TypesPostableBulkInviteRequest:
properties:
invites:
items:
$ref: '#/components/schemas/TypesPostableInvite'
type: array
required:
- invites
type: object
TypesPostableForgotPassword:
properties:
email:
@@ -2196,6 +2205,8 @@ components:
type: string
role:
type: string
status:
type: string
updatedAt:
format: date-time
type: string
@@ -3538,9 +3549,7 @@ paths:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/TypesPostableInvite'
type: array
$ref: '#/components/schemas/TypesPostableBulkInviteRequest'
responses:
"201":
description: Created

View File

@@ -0,0 +1,216 @@
# Query Range v5 — Design Principles & Architectural Contracts
## Purpose of This Document
This document defines the design principles, invariants, and architectural contracts of the Query Range v5 system. It is intended for the authors working on the querier and querier related parts codebase. Any change to the system must align with the principles described here. If a change would violate a principle, it must be flagged and discussed.
---
## Core Architectural Principle
**The user speaks OpenTelemetry. The storage speaks ClickHouse. The system translates between them. These two worlds must never leak into each other.**
Every design choice in Query Range flows from this separation. The user-facing API surface deals exclusively in `TelemetryFieldKey`: a representation of fields as they exist in the OpenTelemetry data model. The storage layer deals in ClickHouse column expressions, table names, and SQL fragments. The translation between them is mediated by a small set of composable abstractions with strict boundaries.
---
## The Central Type: `TelemetryFieldKey`
`TelemetryFieldKey` is the atomic unit of the entire query system. Every filter, aggregation, group-by, order-by, and select operation is expressed in terms of field keys. Understanding its design contracts is non-negotiable.
### Identity
A field key is identified by three dimensions:
- **Name** — the field name as the user knows it (`service.name`, `http.method`, `trace_id`)
- **FieldContext** — where the field lives in the OTel model (`resource`, `attribute`, `span`, `log`, `body`, `scope`, `event`, `metric`)
- **FieldDataType** — the data type (`string`, `bool`, `number`/`float64`/`int64`, array variants)
### Invariant: Same name does not mean same field
`status` as an attribute, `status` as a body JSON key, and `status` as a span-level field are **three different fields**. The context disambiguates. Code that resolves or compares field keys must always consider all three dimensions, never just the name.
### Invariant: Normalization happens once, at the boundary
`TelemetryFieldKey.Normalize()` is called during JSON unmarshaling. After normalization, the text representation `resource.service.name:string` and the programmatic construction `{Name: "service.name", FieldContext: Resource, FieldDataType: String}` are identical.
**Consequence:** Downstream code must never re-parse or re-normalize field keys. If you find yourself splitting on `.` or `:` deep in the pipeline, something is wrong — the normalization should have already happened.
### Invariant: The text format is `context.name:datatype`
Parsing rules (implemented in `GetFieldKeyFromKeyText` and `Normalize`):
1. Data type is extracted from the right, after the last `:`.
2. Field context is extracted from the left, before the first `.`, if it matches a known context prefix.
3. Everything remaining is the name.
Special case: `log.body.X` normalizes to `{FieldContext: body, Name: X}` — the `log.body.` prefix collapses because body fields under log are a nested context.
### Invariant: Historical aliases must be preserved
The `fieldContexts` map includes aliases (`tag` -> `attribute`, `spanfield` -> `span`, `logfield` -> `log`). These exist because older database entries use these names. Removing or changing these aliases will break existing saved queries and dashboard configurations.
---
## The Abstraction Stack
The query pipeline is built from four interfaces that compose vertically. Each layer has a single responsibility. Each layer depends only on the layers below it. This layering is intentional and must be preserved.
```
StatementBuilder <- Orchestrates everything into executable SQL
├── AggExprRewriter <- Rewrites aggregation expressions (maps field refs to columns)
├── ConditionBuilder <- Builds WHERE predicates (field + operator + value -> SQL)
└── FieldMapper <- Maps TelemetryFieldKey -> ClickHouse column expression
```
### FieldMapper
**Contract:** Given a `TelemetryFieldKey`, return a ClickHouse column expression that yields the value for that field when used in a SELECT.
**Principle:** This is the *only* place where field-to-column translation happens. No other layer should contain knowledge of how fields map to storage. If you need a column expression, go through the FieldMapper.
**Why:** The user says `http.request.method`. ClickHouse might store it as `attributes_string['http.request.method']`, or as a materialized column `` `attribute_string_http$$request$$method` ``, or via a JSON access path in a body column. This variation is entirely contained within the FieldMapper. Everything above it is storage-agnostic.
### ConditionBuilder
**Contract:** Given a field key, an operator, and a value, produce a valid SQL predicate for a WHERE clause.
**Dependency:** Uses FieldMapper for the left-hand side of the condition.
**Principle:** The ConditionBuilder owns all the complexity of operator semantics, i.e type casting, array operators (`hasAny`/`hasAll` vs `=`), existence checks, and negative operator behavior. This complexity must not leak upward into the StatementBuilder.
### AggExprRewriter
**Contract:** Given a user-facing aggregation expression like `sum(duration_nano)`, resolve field references within it and produce valid ClickHouse SQL.
**Dependency:** Uses FieldMapper to resolve field names within expressions.
**Principle:** Aggregation expressions are user-authored strings that contain field references. The rewriter parses them, identifies field references, resolves each through the FieldMapper, and reassembles the expression.
### StatementBuilder
**Contract:** Given a complete `QueryBuilderQuery`, a time range, and a request type, produces an executable SQL statement.
**Dependency:** Uses all three abstractions above.
**Principle:** This is the composition layer. It does not contain field mapping logic, condition building logic, or expression rewriting logic. It orchestrates the other abstractions. If you find storage-specific logic creeping into the StatementBuilder, push it down into the appropriate abstraction.
### Invariant: No layer skipping
The StatementBuilder must not call FieldMapper directly to build conditions, it goes through the ConditionBuilder. The AggExprRewriter must not hardcode column names, it goes through the FieldMapper. Skipping layers creates hidden coupling and makes the system fragile to storage changes.
---
## Design Decisions as Constraints
### Constraint: Formula evaluation happens in Go, not in ClickHouse
Formulas (`A + B`, `A / B`, `sqrt(A*A + B*B)`) are evaluated application-side by `FormulaEvaluator`, not via ClickHouse JOINs.
**Why this is a constraint, not just an implementation choice:** The original JOIN-based approach was abandoned because ClickHouse evaluates joins right-to-left, serializing execution unnecessarily. Running queries independently allows parallelism and caching of intermediate results. Any future optimization must not reintroduce the JOIN pattern without solving the serialization problem.
**Consequence:** Individual query results must be independently cacheable. Formula evaluation must handle label matching, timestamp alignment, and missing values without requiring the queries to coordinate at the SQL level.
### Constraint: Zero-defaulting is aggregation-dependent
Only additive/counting aggregations (`count`, `count_distinct`, `sum`, `rate`) default missing values to zero. Statistical aggregations (`avg`, `min`, `max`, percentiles) must show gaps.
**Why:** Absence of data has different meanings. No error requests in a time bucket means error count = 0. No requests at all means average latency is *unknown*, not 0. Conflating these is a correctness bug, not a display preference.
**Enforcement:** `GetQueriesSupportingZeroDefault` determines which queries can default to zero. The `FormulaEvaluator` consumes this via `canDefaultZero`. Changes to aggregation handling must preserve this distinction.
### Constraint: Existence semantics differ for positive vs negative operators
- **Positive operators** (`=`, `>`, `LIKE`, `IN`, etc.) implicitly assert field existence. `http.method = GET` means "the field exists AND equals GET".
- **Negative operators** (`!=`, `NOT IN`, `NOT LIKE`, etc.) do **not** add an existence check. `http.method != GET` includes records where the field doesn't exist at all.
**Why:** The user's intent with negative operators is ambiguous. Rather than guess, we take the broader interpretation. Users can add an explicit `EXISTS` filter if they want the narrower one. This is documented in `AddDefaultExistsFilter`.
**Consequence:** Any new operator must declare its existence behavior in `AddDefaultExistsFilter`. Do not add operators without considering this.
### Constraint: Post-processing functions operate on result sets, not in SQL
Functions like `cutOffMin`, `ewma`, `median`, `timeShift`, `fillZero`, `runningDiff`, and `cumulativeSum` are applied in Go on the returned time series, not pushed into ClickHouse SQL.
**Why:** These are sequential time-series transformations that require complete, ordered result sets. Pushing them into SQL would complicate query generation, prevent caching of raw results, and make the functions harder to test. They are applied via `ApplyFunctions` after query execution.
**Consequence:** New time-series transformation functions should follow this pattern i.e implement them as Go functions on `*TimeSeries`, not as SQL modifications.
### Constraint: The API surface rejects unknown fields with suggestions
All request types use custom `UnmarshalJSON` that calls `DisallowUnknownFields`. Unknown fields trigger error messages with Levenshtein-based suggestions ("did you mean: 'groupBy'?").
**Why:** Silent acceptance of unknown fields causes subtle bugs. A misspelled `groupBy` results in ungrouped data with no indication of what went wrong. Failing fast with suggestions turns errors into actionable feedback.
**Consequence:** Any new request type or query spec struct must implement custom unmarshaling with `UnmarshalJSONWithContext`. Do not use default `json.Unmarshal` for user-facing types.
### Constraint: Validation is context-sensitive to request type
What's valid depends on the `RequestType`. For aggregation requests (`time_series`, `scalar`, `distribution`), fields like `groupBy`, `aggregations`, `having`, and aggregation-referenced `orderBy` are validated. For non-aggregation requests (`raw`, `raw_stream`, `trace`), these fields are ignored.
**Why:** A raw log query doesn't have aggregations, so requiring `aggregations` would be wrong. But a time-series query without aggregations is meaningless. The validation rules are request-type-aware to avoid both false positives and false negatives.
**Consequence:** When adding new fields to query specs, consider which request types they apply to and gate validation accordingly.
---
## The Composite Query Model
### Structure
A `QueryRangeRequest` contains a `CompositeQuery` which holds `[]QueryEnvelope`. Each envelope is a discriminated union: a `Type` field determines how `Spec` is decoded.
### Invariant: Query names are unique within a composite query
Builder queries must have unique names. Formulas reference queries by name (`A`, `B`, `A.0`, `A.my_alias`). Duplicate names would make formula evaluation ambiguous.
### Invariant: Multi-aggregation uses indexed or aliased references
A single builder query can have multiple aggregations. They are accessed in formulas via:
- Index: `A.0`, `A.1` (zero-based)
- Alias: `A.total`, `A.error_count`
The default (just `A`) resolves to index 0. This is the formula evaluation contract and must be preserved.
### Invariant: Type-specific decoding through signal detection
Builder queries are decoded by first peeking at the `signal` field in the raw JSON, then unmarshaling into the appropriate generic type (`QueryBuilderQuery[TraceAggregation]`, `QueryBuilderQuery[LogAggregation]`, `QueryBuilderQuery[MetricAggregation]`). This two-pass decoding is intentional — it allows each signal to have its own aggregation schema while sharing the query structure.
---
## The Metadata Layer
### MetadataStore
The `MetadataStore` interface provides runtime field discovery and type resolution. It answers questions like "what fields exist for this signal?" and "what are the data types of field X?".
### Principle: Fields can be ambiguous until resolved
The same name can map to multiple `TelemetryFieldKey` variants (different contexts, different types). The metadata store returns *all* variants. Resolution to a single field happens during query building, using the query's signal and any explicit context/type hints from the user.
**Consequence:** Code that calls `GetKey` or `GetKeys` must handle multiple results. Do not assume a name maps to a single field.
### Principle: Materialized fields are a performance optimization, not a semantic distinction
A materialized field and its non-materialized equivalent represent the same logical field. The `Materialized` flag tells the FieldMapper to generate a simpler column expression. The user should never need to know whether a field is materialized.
### Principle: JSON body fields require access plans
Fields inside JSON body columns (`body.response.errors[].code`) need pre-computed `JSONAccessPlan` trees that encode the traversal path, including branching at array boundaries between `Array(JSON)` and `Array(Dynamic)` representations. These plans are computed during metadata resolution, not during query execution.
---
## Summary of Inviolable Rules
1. **User-facing types never contain ClickHouse column names or SQL fragments.**
2. **Field-to-column translation only happens in FieldMapper.**
3. **Normalization happens once at the API boundary, never deeper.**
4. **Historical aliases in fieldContexts and fieldDataTypes must not be removed.**
5. **Formula evaluation stays in Go — do not push it into ClickHouse JOINs.**
6. **Zero-defaulting is aggregation-type-dependent — do not universally default to zero.**
7. **Positive operators imply existence, negative operators do not.**
8. **Post-processing functions operate on Go result sets, not in SQL.**
9. **All user-facing types reject unknown JSON fields with suggestions.**
10. **Validation rules are gated by request type.**
11. **Query names must be unique within a composite query.**
12. **The four-layer abstraction stack (FieldMapper -> ConditionBuilder -> AggExprRewriter -> StatementBuilder) must not be bypassed or flattened.**

View File

@@ -176,6 +176,7 @@ func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource
typeables = append(typeables, register.MustGetTypeables()...)
}
typeables = append(typeables, provider.MustGetTypeables()...)
resources := make([]*authtypes.Resource, 0)
for _, typeable := range typeables {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})

View File

@@ -170,7 +170,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}

View File

@@ -2525,6 +2525,13 @@ export interface TypesPostableAcceptInviteDTO {
token?: string;
}
export interface TypesPostableBulkInviteRequestDTO {
/**
* @type array
*/
invites: TypesPostableInviteDTO[];
}
export interface TypesPostableForgotPasswordDTO {
/**
* @type string
@@ -2665,6 +2672,10 @@ export interface TypesUserDTO {
* @type string
*/
role?: string;
/**
* @type string
*/
status?: string;
/**
* @type string
* @format date-time

View File

@@ -41,6 +41,7 @@ import type {
TypesChangePasswordRequestDTO,
TypesPostableAcceptInviteDTO,
TypesPostableAPIKeyDTO,
TypesPostableBulkInviteRequestDTO,
TypesPostableForgotPasswordDTO,
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
@@ -671,14 +672,14 @@ export const useAcceptInvite = <
* @summary Create bulk invite
*/
export const createBulkInvite = (
typesPostableInviteDTO: BodyType<TypesPostableInviteDTO[]>,
typesPostableBulkInviteRequestDTO: BodyType<TypesPostableBulkInviteRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/invite/bulk`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableInviteDTO,
data: typesPostableBulkInviteRequestDTO,
signal,
});
};
@@ -690,13 +691,13 @@ export const getCreateBulkInviteMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data: BodyType<TypesPostableInviteDTO[]> },
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data: BodyType<TypesPostableInviteDTO[]> },
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
> => {
const mutationKey = ['createBulkInvite'];
@@ -710,7 +711,7 @@ export const getCreateBulkInviteMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createBulkInvite>>,
{ data: BodyType<TypesPostableInviteDTO[]> }
{ data: BodyType<TypesPostableBulkInviteRequestDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -723,7 +724,7 @@ export const getCreateBulkInviteMutationOptions = <
export type CreateBulkInviteMutationResult = NonNullable<
Awaited<ReturnType<typeof createBulkInvite>>
>;
export type CreateBulkInviteMutationBody = BodyType<TypesPostableInviteDTO[]>;
export type CreateBulkInviteMutationBody = BodyType<TypesPostableBulkInviteRequestDTO>;
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -736,13 +737,13 @@ export const useCreateBulkInvite = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data: BodyType<TypesPostableInviteDTO[]> },
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createBulkInvite>>,
TError,
{ data: BodyType<TypesPostableInviteDTO[]> },
{ data: BodyType<TypesPostableBulkInviteRequestDTO> },
TContext
> => {
const mutationOptions = getCreateBulkInviteMutationOptions(options);

View File

@@ -94,19 +94,13 @@ export const interceptorRejected = async (
afterLogin(response.data.accessToken, response.data.refreshToken, true);
try {
const reResponse = await axios(
`${value.config.baseURL}${value.config.url?.substring(1)}`,
{
method: value.config.method,
headers: {
...value.config.headers,
Authorization: `Bearer ${response.data.accessToken}`,
},
data: {
...JSON.parse(value.config.data || '{}'),
},
const reResponse = await axios({
...value.config,
headers: {
...value.config.headers,
Authorization: `Bearer ${response.data.accessToken}`,
},
);
});
return await Promise.resolve(reResponse);
} catch (error) {

View File

@@ -0,0 +1,142 @@
.create-sa-modal {
max-width: 530px;
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
box-shadow: 0 4px 9px 0 rgba(0, 0, 0, 0.04);
[data-slot='dialog-header'] {
padding: var(--padding-4);
border-bottom: 1px solid var(--secondary);
flex-shrink: 0;
background: transparent;
margin: 0;
}
[data-slot='dialog-title'] {
font-family: Inter, sans-serif;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--label-base-400-line-height);
letter-spacing: -0.065px;
color: var(--bg-base-white);
margin: 0;
}
[data-slot='dialog-description'] {
padding: 0;
.create-sa-modal__content {
padding: var(--padding-4);
}
}
}
.create-sa-form {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
.ant-form-item {
margin-bottom: var(--spacing-4);
}
.ant-form-item-label > label {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
font-size: var(--paragraph-base-400-font-size);
border-radius: 2px;
width: 100%;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--primary);
box-shadow: none;
}
}
&__select {
width: 100%;
.ant-select-selector {
min-height: 32px;
border-radius: 2px;
background-color: var(--l2-background) !important;
border: 1px solid var(--border) !important;
padding: 0 var(--padding-2) !important;
.ant-select-selection-placeholder {
color: var(--l3-foreground);
opacity: 0.4;
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
line-height: 32px;
}
.ant-select-selection-item {
font-size: var(--paragraph-base-400-font-size);
letter-spacing: -0.07px;
color: var(--bg-base-white);
}
}
.ant-select-arrow {
color: var(--foreground);
}
&.ant-select-focused .ant-select-selector,
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--primary);
}
}
&__helper {
font-size: var(--paragraph-small-400-font-size);
color: var(--l3-foreground);
margin: calc(var(--spacing-2) * -1) 0 var(--spacing-4) 0;
line-height: var(--paragraph-small-400-line-height);
}
}
.create-sa-modal__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
padding: 0 var(--padding-4);
height: 56px;
min-height: 56px;
border-top: 1px solid var(--secondary);
gap: var(--spacing-4);
flex-shrink: 0;
}
.lightMode {
.create-sa-modal {
[data-slot='dialog-title'] {
color: var(--bg-base-black);
}
}
.create-sa-form {
&__select {
.ant-select-selector {
.ant-select-selection-item {
color: var(--bg-base-black);
}
}
}
}
}

View File

@@ -0,0 +1,181 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { Form, Input } from 'antd';
import { useCreateServiceAccount } from 'api/generated/services/serviceaccount';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import './CreateServiceAccountModal.styles.scss';
interface CreateServiceAccountModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
interface FormValues {
name: string;
email: string;
roles: string[];
}
function CreateServiceAccountModal({
open,
onClose,
onSuccess,
}: CreateServiceAccountModalProps): JSX.Element {
const [form] = Form.useForm<FormValues>();
const [isSubmitting, setIsSubmitting] = useState(false);
const [submittable, setSubmittable] = useState(false);
const values = Form.useWatch([], form);
useEffect(() => {
form
.validateFields({ validateOnly: true })
.then(() => setSubmittable(true))
.catch(() => setSubmittable(false));
}, [values, form]);
const { mutateAsync: createServiceAccount } = useCreateServiceAccount();
const {
roles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
const handleClose = useCallback((): void => {
form.resetFields();
onClose();
}, [form, onClose]);
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
setIsSubmitting(true);
await createServiceAccount({
data: {
name: values.name.trim(),
email: values.email.trim(),
roles: values.roles,
},
});
toast.success('Service account created successfully', { richColors: true });
form.resetFields();
onSuccess();
onClose();
} catch (err: unknown) {
// If it's a form validation error (no message property typical of AntD), skip
if (err && typeof err === 'object' && 'errorFields' in err) {
return;
}
const errorMessage =
err instanceof Error ? err.message : 'An error occurred';
toast.error(`Failed to create service account: ${errorMessage}`, {
richColors: true,
});
} finally {
setIsSubmitting(false);
}
}, [form, createServiceAccount, onSuccess, onClose]);
return (
<DialogWrapper
title="New Service Account"
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
showCloseButton
width="narrow"
className="create-sa-modal"
disableOutsideClick={false}
>
<div className="create-sa-modal__content">
<Form form={form} layout="vertical" className="create-sa-form">
<Form.Item
name="name"
label="Name"
rules={[{ required: true, message: 'Name is required' }]}
className="create-sa-form__item"
>
<Input placeholder="Enter a name" className="create-sa-form__input" />
</Form.Item>
<Form.Item
name="email"
label="Email Address"
rules={[
{ required: true, message: 'Email Address is required' },
{ type: 'email', message: 'Please enter a valid email address' },
]}
className="create-sa-form__item"
>
<Input
type="email"
placeholder="email@example.com"
className="create-sa-form__input"
/>
</Form.Item>
<p className="create-sa-form__helper">
Used only for notifications about this service account. It is not used for
authentication.
</p>
<Form.Item
name="roles"
label="Roles"
rules={[{ required: true, message: 'At least one role is required' }]}
className="create-sa-form__item"
>
<RolesSelect
mode="multiple"
roles={roles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
placeholder="Select roles"
className="create-sa-form__select"
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.create-sa-modal') as HTMLElement) ||
document.body
}
/>
</Form.Item>
</Form>
</div>
<DialogFooter className="create-sa-modal__footer">
<Button
type="button"
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
onClick={handleSubmit}
disabled={isSubmitting || !submittable}
>
{isSubmitting ? 'Creating...' : 'Create Service Account'}
</Button>
</DialogFooter>
</DialogWrapper>
);
}
export default CreateServiceAccountModal;

View File

@@ -14,6 +14,7 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import HavingFilter from './HavingFilter/HavingFilter';
import { buildDefaultLegendFromGroupBy } from './utils';
import './QueryAddOns.styles.scss';
@@ -250,12 +251,33 @@ function QueryAddOns({
}, [panelType, isListViewPanel, query, showReduceTo]);
const handleOptionClick = (e: RadioChangeEvent): void => {
if (selectedViews.find((view) => view.key === e.target.value.key)) {
setSelectedViews(
selectedViews.filter((view) => view.key !== e.target.value.key),
const clickedAddOn = e.target.value as AddOn;
const isAlreadySelected = selectedViews.some(
(view) => view.key === clickedAddOn.key,
);
if (isAlreadySelected) {
setSelectedViews((prev) =>
prev.filter((view) => view.key !== clickedAddOn.key),
);
} else {
setSelectedViews([...selectedViews, e.target.value]);
// When enabling Legend format for the first time with an empty legend
// and existing group-by keys, prefill the legend using all group-by keys.
// This keeps existing custom legends intact and only helps seed a sensible default.
if (
clickedAddOn.key === ADD_ONS_KEYS.LEGEND_FORMAT &&
isEmpty(query?.legend) &&
Array.isArray(query.groupBy) &&
query.groupBy.length > 0
) {
const defaultLegend = buildDefaultLegendFromGroupBy(query.groupBy);
if (defaultLegend) {
handleChangeQueryLegend(defaultLegend);
}
}
setSelectedViews((prev) => [...prev, clickedAddOn]);
}
};
@@ -288,12 +310,9 @@ function QueryAddOns({
[handleSetQueryData, index, query],
);
const handleRemoveView = useCallback(
(key: string): void => {
setSelectedViews(selectedViews.filter((view) => view.key !== key));
},
[selectedViews],
);
const handleRemoveView = useCallback((key: string): void => {
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
}, []);
const handleChangeQueryLegend = useCallback(
(value: string) => {
@@ -379,8 +398,8 @@ function QueryAddOns({
<div className="input">
<HavingFilter
onClose={(): void => {
setSelectedViews(
selectedViews.filter((view) => view.key !== 'having'),
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'having'),
);
}}
onChange={handleChangeHaving}
@@ -399,7 +418,9 @@ function QueryAddOns({
initialValue={query?.limit ?? undefined}
placeholder="Enter limit"
onClose={(): void => {
setSelectedViews(selectedViews.filter((view) => view.key !== 'limit'));
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'limit'),
);
}}
closeIcon={<ChevronUp size={16} />}
/>
@@ -482,8 +503,8 @@ function QueryAddOns({
onChange={handleChangeQueryLegend}
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
onClose={(): void => {
setSelectedViews(
selectedViews.filter((view) => view.key !== 'legend_format'),
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'legend_format'),
);
}}
closeIcon={<ChevronUp size={16} />}

View File

@@ -0,0 +1,16 @@
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
export const buildDefaultLegendFromGroupBy = (
groupBy: IBuilderQuery['groupBy'],
): string | null => {
const segments = groupBy
.map((item) => item?.key)
.filter((key): key is string => Boolean(key))
.map((key) => `${key} = {{${key}}}`);
if (segments.length === 0) {
return null;
}
return segments.join(', ');
};

View File

@@ -275,4 +275,59 @@ describe('QueryAddOns', () => {
});
});
});
it('auto-generates legend from all groupBy keys when enabling Legend format with empty legend', async () => {
const user = userEvent.setup();
const query = baseQuery({
groupBy: [{ key: 'service.name' }, { key: 'operation' }],
});
render(
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
const legendTab = screen.getByTestId('query-add-on-legend_format');
await user.click(legendTab);
expect(mockHandleChangeQueryData).toHaveBeenCalledWith(
'legend',
'service.name = {{service.name}}, operation = {{operation}}',
);
});
it('does not override existing legend when enabling Legend format', async () => {
const user = userEvent.setup();
const query = baseQuery({
legend: 'existing legend',
groupBy: [{ key: 'service.name' }],
});
render(
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
const legendTab = screen.getByTestId('query-add-on-legend_format');
await user.click(legendTab);
expect(mockHandleChangeQueryData).not.toHaveBeenCalledWith(
'legend',
expect.anything(),
);
});
});

View File

@@ -0,0 +1,25 @@
.roles-select-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
color: var(--bg-cherry-500);
font-size: 12px;
&__msg {
display: flex;
align-items: center;
gap: 6px;
}
&__retry-btn {
display: flex;
align-items: center;
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--bg-cherry-500);
}
}

View File

@@ -0,0 +1,171 @@
import { CircleAlert, RefreshCw } from '@signozhq/icons';
import { Checkbox, Select } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useListRoles } from 'api/generated/services/role';
import type { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import APIError from 'types/api/error';
import './RolesSelect.styles.scss';
export interface RoleOption {
label: string;
value: string;
}
export function useRoles(): {
roles: RoletypesRoleDTO[];
isLoading: boolean;
isError: boolean;
error: APIError | undefined;
refetch: () => void;
} {
const { data, isLoading, isError, error, refetch } = useListRoles();
return {
roles: data?.data ?? [],
isLoading,
isError,
error: convertToApiError(error),
refetch,
};
}
export function getRoleOptions(roles: RoletypesRoleDTO[]): RoleOption[] {
return roles.map((role) => ({
label: role.name ?? '',
value: role.name ?? '',
}));
}
function ErrorContent({
error,
onRefetch,
}: {
error?: APIError;
onRefetch?: () => void;
}): JSX.Element {
const errorMessage = error?.message || 'Failed to load roles';
return (
<div className="roles-select-error">
<span className="roles-select-error__msg">
<CircleAlert size={12} />
{errorMessage}
</span>
{onRefetch && (
<button
type="button"
onClick={(e): void => {
e.stopPropagation();
onRefetch();
}}
className="roles-select-error__retry-btn"
title="Retry"
>
<RefreshCw size={12} />
</button>
)}
</div>
);
}
interface BaseProps {
id?: string;
placeholder?: string;
className?: string;
getPopupContainer?: (trigger: HTMLElement) => HTMLElement;
roles?: RoletypesRoleDTO[];
loading?: boolean;
isError?: boolean;
error?: APIError;
onRefetch?: () => void;
}
interface SingleProps extends BaseProps {
mode?: 'single';
value?: string;
onChange?: (role: string) => void;
}
interface MultipleProps extends BaseProps {
mode: 'multiple';
value?: string[];
onChange?: (roles: string[]) => void;
}
export type RolesSelectProps = SingleProps | MultipleProps;
function RolesSelect(props: RolesSelectProps): JSX.Element {
const externalRoles = props.roles;
const {
data,
isLoading: internalLoading,
isError: internalError,
error: internalErrorObj,
refetch: internalRefetch,
} = useListRoles({
query: { enabled: externalRoles === undefined },
});
const roles = externalRoles ?? data?.data ?? [];
const options = getRoleOptions(roles);
const {
mode,
id,
placeholder = 'Select role',
className,
getPopupContainer,
loading = internalLoading,
isError = internalError,
error = convertToApiError(internalErrorObj),
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
} = props;
const notFoundContent = isError ? (
<ErrorContent error={error} onRefetch={onRefetch} />
) : undefined;
if (mode === 'multiple') {
const { value = [], onChange } = props as MultipleProps;
return (
<Select
id={id}
mode="multiple"
value={value}
onChange={onChange}
placeholder={placeholder}
className={className}
loading={loading}
notFoundContent={notFoundContent}
options={options}
optionRender={(option): JSX.Element => (
<Checkbox
checked={value.includes(option.value as string)}
style={{ pointerEvents: 'none' }}
>
{option.label}
</Checkbox>
)}
getPopupContainer={getPopupContainer}
/>
);
}
const { value, onChange } = props as SingleProps;
return (
<Select
id={id}
value={value}
onChange={onChange}
placeholder={placeholder}
className={className}
loading={loading}
notFoundContent={notFoundContent}
options={options}
getPopupContainer={getPopupContainer}
/>
);
}
export default RolesSelect;

View File

@@ -0,0 +1,2 @@
export type { RoleOption, RolesSelectProps } from './RolesSelect';
export { default, getRoleOptions, useRoles } from './RolesSelect';

View File

@@ -0,0 +1,185 @@
.add-key-modal {
&__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
// Expiry segmented toggle using ToggleGroup
&__expiry-toggle {
display: flex;
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
padding: 0;
gap: 0;
&-btn {
flex: 1;
height: 32px;
border-radius: 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
justify-content: center;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--foreground);
&:last-child {
border-right: none;
}
&[data-state='on'] {
background: var(--l2-background);
color: var(--l1-foreground);
}
}
}
&__datepicker {
width: 100%;
height: 32px;
.ant-picker {
background: var(--l2-background);
border-color: var(--border);
border-radius: 2px;
width: 100%;
height: 32px;
input {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
}
.ant-picker-suffix {
color: var(--foreground);
}
&:hover {
border-color: var(--foreground);
}
}
}
&__key-display {
display: flex;
align-items: center;
height: 32px;
background: var(--l2-background);
border: 1px solid var(--border);
border-radius: 2px;
overflow: hidden;
}
&__key-text {
flex: 1;
min-width: 0;
padding: 0 var(--padding-2);
font-size: var(--font-size-sm);
color: var(--l1-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: monospace;
}
&__copy-btn {
flex-shrink: 0;
height: 32px;
border-radius: 0 2px 2px 0;
border-top: none;
border-right: none;
border-bottom: none;
border-left: 1px solid var(--border);
min-width: 40px;
}
&__callout {
display: flex;
align-items: flex-start;
gap: var(--spacing-3);
background: rgba(78, 116, 248, 0.1);
border: 1px solid rgba(78, 116, 248, 0.2);
border-radius: 4px;
padding: var(--padding-3) var(--padding-4);
font-size: var(--font-size-sm);
color: var(--foreground);
line-height: 1.5;
}
&__callout-icon {
color: var(--primary);
flex-shrink: 0;
margin-top: 2px;
}
&__expiry-meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__expiry-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--padding-4);
border-top: 1px solid var(--border);
margin-top: var(--margin-2);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__learn-more {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
color: var(--primary);
font-size: var(--font-size-sm);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,268 @@
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogWrapper } from '@signozhq/dialog';
import { ArrowUpRight, Check, Copy, Info } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { DatePicker } from 'antd';
import { useCreateServiceAccountKey } from 'api/generated/services/serviceaccount';
import type { ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO } from 'api/generated/services/sigNoz.schemas';
import type { Dayjs } from 'dayjs';
import './AddKeyModal.styles.scss';
interface AddKeyModalProps {
open: boolean;
accountId: string;
onClose: () => void;
onSuccess: () => void;
}
type Phase = 'form' | 'created';
type ExpiryMode = 'none' | 'date';
function AddKeyModal({
open,
accountId,
onClose,
onSuccess,
}: AddKeyModalProps): JSX.Element {
const [phase, setPhase] = useState<Phase>('form');
const [keyName, setKeyName] = useState('');
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
const [expiryDate, setExpiryDate] = useState<Dayjs | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [
createdKey,
setCreatedKey,
] = useState<ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO | null>(null);
const [hasCopied, setHasCopied] = useState(false);
useEffect(() => {
if (open) {
setPhase('form');
setKeyName('');
setExpiryMode('none');
setExpiryDate(null);
setIsSubmitting(false);
setCreatedKey(null);
setHasCopied(false);
}
}, [open]);
const { mutateAsync: createKey } = useCreateServiceAccountKey();
const handleCreate = useCallback(async (): Promise<void> => {
if (!keyName.trim()) {
return;
}
setIsSubmitting(true);
try {
const expiresAt =
expiryMode === 'date' && expiryDate ? expiryDate.unix() : 0;
const response = await createKey({
pathParams: { id: accountId },
data: { name: keyName.trim(), expires_at: expiresAt },
});
const keyData = response?.data;
if (keyData) {
setCreatedKey(keyData);
setPhase('created');
}
} catch {
toast.error('Failed to create key', { richColors: true });
} finally {
setIsSubmitting(false);
}
}, [keyName, expiryMode, expiryDate, accountId, createKey]);
const handleCopy = useCallback(async (): Promise<void> => {
if (!createdKey?.key) {
return;
}
try {
await navigator.clipboard.writeText(createdKey.key);
setHasCopied(true);
setTimeout(() => setHasCopied(false), 2000);
toast.success('Key copied to clipboard', { richColors: true });
} catch {
toast.error('Failed to copy key', { richColors: true });
}
}, [createdKey]);
const handleClose = useCallback((): void => {
if (phase === 'created') {
onSuccess();
}
onClose();
}, [phase, onSuccess, onClose]);
const expiryLabel = (): string => {
if (expiryMode === 'none' || !expiryDate) {
return 'Never';
}
try {
return expiryDate.format('MMM D, YYYY');
} catch {
return 'Never';
}
};
const title = phase === 'form' ? 'Add a New Key' : 'Key Created Successfully';
return (
<DialogWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
title={title}
width="base"
className="add-key-modal"
showCloseButton
disableOutsideClick={false}
>
{phase === 'form' && (
<div className="add-key-modal__form">
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="key-name">
Name <span style={{ color: 'var(--destructive)' }}>*</span>
</label>
<Input
id="key-name"
value={keyName}
onChange={(e): void => setKeyName(e.target.value)}
placeholder="Enter key name e.g.: Service Owner"
className="add-key-modal__input"
/>
</div>
<div className="add-key-modal__field">
<span className="add-key-modal__label">Expiration</span>
<ToggleGroup
type="single"
value={expiryMode}
onValueChange={(val): void => {
if (val) {
setExpiryMode(val as ExpiryMode);
if (val === 'none') {
setExpiryDate(null);
}
}
}}
className="add-key-modal__expiry-toggle"
>
<ToggleGroupItem
value="none"
className="add-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value="date"
className="add-key-modal__expiry-toggle-btn"
>
Set Expiration Date
</ToggleGroupItem>
</ToggleGroup>
</div>
{expiryMode === 'date' && (
<div className="add-key-modal__field">
<label className="add-key-modal__label" htmlFor="expiry-date">
Expiration Date
</label>
<div className="add-key-modal__datepicker">
<DatePicker
id="expiry-date"
value={expiryDate}
onChange={(date): void => setExpiryDate(date)}
style={{ width: '100%', height: 32 }}
popupClassName="add-key-modal__datepicker-popup"
/>
</div>
</div>
)}
<div className="add-key-modal__footer">
<a
href="https://signoz.io/docs/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="add-key-modal__learn-more"
>
Learn more about Service Account Keys
<ArrowUpRight size={12} />
</a>
<div className="add-key-modal__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!keyName.trim() || isSubmitting}
onClick={handleCreate}
>
{isSubmitting ? 'Creating...' : 'Create Key'}
</Button>
</div>
</div>
</div>
)}
{phase === 'created' && createdKey && (
<div className="add-key-modal__form">
<div className="add-key-modal__field">
<span className="add-key-modal__label">API Key</span>
<div className="add-key-modal__key-display">
<span className="add-key-modal__key-text">{createdKey.key}</span>
<Button
variant="outlined"
color="secondary"
size="sm"
onClick={handleCopy}
className="add-key-modal__copy-btn"
>
{hasCopied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</div>
</div>
<div className="add-key-modal__expiry-meta">
<span className="add-key-modal__expiry-label">Expiration</span>
<Badge color="vanilla">{expiryLabel()}</Badge>
</div>
<div className="add-key-modal__callout">
<Info size={12} className="add-key-modal__callout-icon" />
<span>
Store the key securely. This is the only time it will be displayed.
</span>
</div>
<div className="add-key-modal__footer">
<span />
<div className="add-key-modal__footer-right">
<Button variant="solid" color="primary" size="sm" onClick={handleClose}>
Done
</Button>
</div>
</div>
</div>
)}
</DialogWrapper>
);
}
export default AddKeyModal;

View File

@@ -0,0 +1,192 @@
.edit-key-modal {
.ant-modal-content {
background: var(--popover);
border: 1px solid var(--secondary);
border-radius: 4px;
padding: 0;
}
.ant-modal-header {
background: transparent;
border-bottom: 1px solid var(--secondary);
padding: var(--padding-4) var(--padding-5);
margin: 0;
}
.ant-modal-title {
font-size: var(--label-large-500-font-size);
font-weight: var(--label-large-500-font-weight);
color: var(--l1-foreground);
}
.ant-modal-close {
color: var(--foreground);
&:hover {
color: var(--l1-foreground);
}
}
.ant-modal-body {
padding: var(--padding-5);
}
.ant-modal-footer {
display: none;
}
&__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__key-display {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
cursor: not-allowed;
opacity: 0.8;
}
&__key-text {
font-size: var(--font-size-sm);
font-family: monospace;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__expiry-toggle {
display: flex;
gap: var(--spacing-2);
&-btn {
flex: 1;
height: 32px;
border-radius: 2px;
font-size: var(--font-size-sm);
&--active {
background: var(--l2-background);
border: 1px solid var(--border);
}
}
}
&__datepicker {
.ant-picker {
background: var(--l2-background);
border-color: var(--border);
border-radius: 2px;
width: 100%;
height: 32px;
input {
color: var(--l1-foreground);
font-size: var(--font-size-sm);
}
.ant-picker-suffix {
color: var(--foreground);
}
&:hover {
border-color: var(--foreground);
}
}
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
border-top: 1px solid var(--secondary);
margin-top: var(--margin-6);
padding-top: var(--padding-4);
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__footer-danger {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--destructive);
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}

View File

@@ -0,0 +1,315 @@
import { useCallback, useEffect, useState } from 'react';
import { Badge } from '@signozhq/badge';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { toast } from '@signozhq/sonner';
import { DatePicker, Modal } from 'antd';
import {
useRevokeServiceAccountKey,
useUpdateServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { formatLastUsed } from './utils';
import './EditKeyModal.styles.scss';
interface EditKeyModalProps {
open: boolean;
accountId: string;
keyItem: ServiceaccounttypesFactorAPIKeyDTO | null;
onClose: () => void;
onSuccess: () => void;
}
type ExpiryMode = 'none' | 'date';
// eslint-disable-next-line sonarjs/cognitive-complexity
function EditKeyModal({
open,
accountId,
keyItem,
onClose,
onSuccess,
}: EditKeyModalProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [localName, setLocalName] = useState('');
const [expiryMode, setExpiryMode] = useState<ExpiryMode>('none');
const [localDate, setLocalDate] = useState<Dayjs | null>(null);
const [isRevokeConfirmOpen, setIsRevokeConfirmOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isRevoking, setIsRevoking] = useState(false);
useEffect(() => {
if (keyItem) {
setLocalName(keyItem.name ?? '');
if (keyItem.expires_at === 0) {
setExpiryMode('none');
setLocalDate(null);
} else {
setExpiryMode('date');
setLocalDate(dayjs.unix(keyItem.expires_at));
}
}
}, [keyItem]);
const originalExpiresAt = keyItem?.expires_at ?? 0;
const currentExpiresAt =
expiryMode === 'none' || !localDate ? 0 : localDate.unix();
const isDirty =
keyItem !== null &&
(localName !== (keyItem.name ?? '') ||
currentExpiresAt !== originalExpiresAt);
const { mutateAsync: updateKey } = useUpdateServiceAccountKey();
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
const handleSave = useCallback(async (): Promise<void> => {
if (!keyItem || !isDirty) {
return;
}
setIsSaving(true);
try {
await updateKey({
pathParams: { id: accountId, fid: keyItem.id },
data: { name: localName, expires_at: currentExpiresAt },
});
toast.success('Key updated successfully', { richColors: true });
onSuccess();
} catch {
toast.error('Failed to update key', { richColors: true });
} finally {
setIsSaving(false);
}
}, [
keyItem,
isDirty,
localName,
currentExpiresAt,
accountId,
updateKey,
onSuccess,
]);
const handleRevoke = useCallback(async (): Promise<void> => {
if (!keyItem) {
return;
}
setIsRevoking(true);
try {
await revokeKey({
pathParams: { id: accountId, fid: keyItem.id },
});
toast.success('Key revoked successfully', { richColors: true });
setIsRevokeConfirmOpen(false);
onSuccess();
} catch {
toast.error('Failed to revoke key', { richColors: true });
} finally {
setIsRevoking(false);
}
}, [keyItem, accountId, revokeKey, onSuccess]);
const handleFormatLastUsed = useCallback(
(lastUsed: Date | null | undefined): string =>
formatLastUsed(lastUsed, formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const expiryDisplayLabel = (): string => {
if (expiryMode === 'none' || !localDate) {
return 'Never';
}
try {
return localDate.format('MMM D, YYYY');
} catch {
return 'Never';
}
};
return (
<>
<Modal
open={open}
onCancel={onClose}
title="Edit Key"
width={530}
footer={null}
className="edit-key-modal"
destroyOnClose
>
<div className="edit-key-modal__form">
{/* Name */}
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-name">
Name
</label>
<Input
id="edit-key-name"
value={localName}
onChange={(e): void => setLocalName(e.target.value)}
className="edit-key-modal__input"
placeholder="Enter key name"
/>
</div>
{/* Key (read-only masked) */}
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-display">
Key
</label>
<div id="edit-key-display" className="edit-key-modal__key-display">
<span className="edit-key-modal__key-text">{keyItem?.key ?? '—'}</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</div>
{/* Expiration toggle */}
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-expiry-toggle">
Expiration
</label>
<div
id="edit-key-expiry-toggle"
className="edit-key-modal__expiry-toggle"
>
<Button
variant={expiryMode === 'none' ? 'solid' : 'ghost'}
color="secondary"
size="sm"
className={`edit-key-modal__expiry-toggle-btn${
expiryMode === 'none'
? ' edit-key-modal__expiry-toggle-btn--active'
: ''
}`}
onClick={(): void => {
setExpiryMode('none');
setLocalDate(null);
}}
>
No Expiration
</Button>
<Button
variant={expiryMode === 'date' ? 'solid' : 'ghost'}
color="secondary"
size="sm"
className={`edit-key-modal__expiry-toggle-btn${
expiryMode === 'date'
? ' edit-key-modal__expiry-toggle-btn--active'
: ''
}`}
onClick={(): void => setExpiryMode('date')}
>
Set Expiration Date
</Button>
</div>
</div>
{expiryMode === 'date' && (
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-datepicker">
Expiration Date
</label>
<div id="edit-key-datepicker" className="edit-key-modal__datepicker">
<DatePicker
value={localDate}
onChange={(date): void => setLocalDate(date)}
style={{ width: '100%', height: 32 }}
/>
</div>
</div>
)}
{/* Expiry meta */}
<div className="edit-key-modal__meta">
<span className="edit-key-modal__meta-label">Expiry</span>
<Badge color="vanilla">{expiryDisplayLabel()}</Badge>
</div>
{/* Last used meta */}
<div className="edit-key-modal__meta">
<span className="edit-key-modal__meta-label">Last Used</span>
<Badge color="vanilla">
{handleFormatLastUsed(keyItem?.last_used ?? null)}
</Badge>
</div>
{/* Footer */}
<div className="edit-key-modal__footer">
<button
type="button"
className="edit-key-modal__footer-danger"
onClick={(): void => setIsRevokeConfirmOpen(true)}
>
<Trash2 size={12} />
Revoke Key
</button>
<div className="edit-key-modal__footer-right">
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</div>
</Modal>
{/* Revoke confirm dialog */}
<DialogWrapper
open={isRevokeConfirmOpen}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setIsRevokeConfirmOpen(false);
}
}}
title={`Revoke ${keyItem?.name ?? 'key'}?`}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">
Revoking this key will permanently invalidate it. Any systems using this
key will lose access immediately.
</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setIsRevokeConfirmOpen(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isRevoking}
onClick={handleRevoke}
>
<Trash2 size={12} />
{isRevoking ? 'Revoking...' : 'Revoke Key'}
</Button>
</DialogFooter>
</DialogWrapper>
</>
);
}
export default EditKeyModal;

View File

@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { Trash2, X } from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { Tooltip } from 'antd';
import {
useListServiceAccountKeys,
useRevokeServiceAccountKey,
} from 'api/generated/services/serviceaccount';
import type { ServiceaccounttypesFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import dayjs from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import EditKeyModal from './EditKeyModal';
import { formatLastUsed } from './utils';
interface KeysTabProps {
accountId: string;
onKeyCountChange: (n: number) => void;
onAddKeyClick: () => void;
}
function formatExpiry(expiresAt: number): JSX.Element {
if (expiresAt === 0) {
return <span className="keys-tab__expiry--never">Never</span>;
}
const expiryDate = dayjs.unix(expiresAt);
if (expiryDate.isBefore(dayjs())) {
return (
<span className="keys-tab__expiry--expired">
{expiryDate.format('MMM D, YYYY')}
</span>
);
}
return <span>{expiryDate.format('MMM D, YYYY')}</span>;
}
function KeysTab({
accountId,
onKeyCountChange,
onAddKeyClick,
}: KeysTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const [
editKey,
setEditKey,
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
const [
revokeTarget,
setRevokeTarget,
] = useState<ServiceaccounttypesFactorAPIKeyDTO | null>(null);
const [isRevoking, setIsRevoking] = useState(false);
const { data: keysData, refetch } = useListServiceAccountKeys({
id: accountId,
});
const keys = keysData?.data ?? [];
useEffect(() => {
onKeyCountChange(keys.length);
}, [keys.length, onKeyCountChange]);
const { mutateAsync: revokeKey } = useRevokeServiceAccountKey();
const handleRevoke = useCallback(async (): Promise<void> => {
if (!revokeTarget) {
return;
}
setIsRevoking(true);
try {
await revokeKey({
pathParams: { id: accountId, fid: revokeTarget.id },
});
toast.success('Key revoked successfully', { richColors: true });
setRevokeTarget(null);
refetch();
} catch {
toast.error('Failed to revoke key', { richColors: true });
} finally {
setIsRevoking(false);
}
}, [revokeTarget, revokeKey, accountId, refetch]);
const handleKeySuccess = useCallback((): void => {
setEditKey(null);
refetch();
}, [refetch]);
const handleFormatLastUsed = useCallback(
(lastUsed: Date | null | undefined): string =>
formatLastUsed(lastUsed, formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
if (keys.length === 0) {
return (
<div className="keys-tab__empty">
<span className="keys-tab__empty-emoji" role="img" aria-label="searching">
🧐
</span>
<p className="keys-tab__empty-text">No keys. Start by creating one.</p>
<button
type="button"
className="keys-tab__learn-more"
onClick={onAddKeyClick}
>
+ Add your first key
</button>
</div>
);
}
return (
<>
<div className="keys-tab__table-wrap">
{/* Header row */}
<div className="keys-tab__table-header">
<span className="keys-tab__col-name">Name</span>
<span className="keys-tab__col-expiry">Expiry</span>
<span className="keys-tab__col-last-used">Last Used</span>
<span className="keys-tab__col-action" />
</div>
{/* Data rows */}
{keys.map((keyItem, idx) => (
<div
key={keyItem.id}
className={`keys-tab__table-row${
idx % 2 === 1 ? ' keys-tab__table-row--alt' : ''
}`}
onClick={(): void => setEditKey(keyItem)}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
setEditKey(keyItem);
}
}}
>
<span className="keys-tab__col-name keys-tab__name-text">
{keyItem.name ?? '—'}
</span>
<span className="keys-tab__col-expiry">
{formatExpiry(keyItem.expires_at)}
</span>
<span className="keys-tab__col-last-used">
{handleFormatLastUsed(keyItem.last_used ?? null)}
</span>
<span className="keys-tab__col-action">
<Tooltip title="Revoke Key">
<Button
variant="ghost"
size="xs"
color="destructive"
onClick={(e): void => {
e.stopPropagation();
setRevokeTarget(keyItem);
}}
className="keys-tab__revoke-btn"
>
<X size={14} />
</Button>
</Tooltip>
</span>
</div>
))}
{/* Count footer */}
<div className="keys-tab__table-footer">
<span className="keys-tab__count">
1 {keys.length} of {keys.length}
</span>
</div>
</div>
{/* Revoke confirm dialog */}
<DialogWrapper
open={revokeTarget !== null}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setRevokeTarget(null);
}
}}
title={`Revoke ${revokeTarget?.name ?? 'key'}?`}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">
Revoking this key will permanently invalidate it. Any systems using this
key will lose access immediately.
</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setRevokeTarget(null)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isRevoking}
onClick={handleRevoke}
>
<Trash2 size={12} />
{isRevoking ? 'Revoking...' : 'Revoke Key'}
</Button>
</DialogFooter>
</DialogWrapper>
<EditKeyModal
open={editKey !== null}
accountId={accountId}
keyItem={editKey}
onClose={(): void => setEditKey(null)}
onSuccess={handleKeySuccess}
/>
</>
);
}
export default KeysTab;

View File

@@ -0,0 +1,158 @@
import { useCallback } from 'react';
import { Badge } from '@signozhq/badge';
import { LockKeyhole } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
interface OverviewTabProps {
account: ServiceAccountRow;
localName: string;
onNameChange: (v: string) => void;
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
availableRoles: RoletypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
rolesErrorObj?: APIError | undefined;
onRefetchRoles?: () => void;
}
function OverviewTab({
account,
localName,
onNameChange,
localRoles,
onRolesChange,
isDisabled,
availableRoles,
rolesLoading,
rolesError,
rolesErrorObj,
onRefetchRoles,
}: OverviewTabProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatTimestamp = useCallback(
(ts: string | null | undefined): string => {
if (!ts) {
return '—';
}
const d = new Date(ts);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.DASH_DATETIME);
},
[formatTimezoneAdjustedTimestamp],
);
return (
<>
{/* Name */}
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<Input
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
className="sa-drawer__input"
placeholder="Enter name"
/>
)}
</div>
{/* Email (always locked) */}
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-email">
Email Address
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.email || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>
{/* Roles */}
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-roles">
Roles
</label>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<div className="sa-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((r) => (
<Badge key={r} color="vanilla">
{r}
</Badge>
))
) : (
<span className="sa-drawer__input-text"></span>
)}
</div>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<RolesSelect
id="sa-roles"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={onRefetchRoles}
value={localRoles}
onChange={onRolesChange}
placeholder="Select roles"
className="sa-drawer__role-select"
getPopupContainer={(triggerNode): HTMLElement =>
(triggerNode?.closest('.sa-drawer') as HTMLElement) || document.body
}
/>
)}
</div>
{/* Meta */}
<div className="sa-drawer__meta">
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Status</span>
{account.status?.toUpperCase() === 'ACTIVE' ? (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
) : (
<Badge color="vanilla" variant="outline">
DISABLED
</Badge>
)}
</div>
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Created At</span>
<Badge color="vanilla">{formatTimestamp(account.createdAt)}</Badge>
</div>
<div className="sa-drawer__meta-item">
<span className="sa-drawer__meta-label">Updated At</span>
<Badge color="vanilla">{formatTimestamp(account.updatedAt)}</Badge>
</div>
</div>
</>
);
}
export default OverviewTab;

View File

@@ -0,0 +1,466 @@
.sa-drawer {
&__layout {
display: flex;
flex-direction: column;
height: calc(100vh - 48px);
}
&__tabs {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--padding-3) var(--padding-4);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
&__tab-group {
// ToggleGroup container — let it inherit default styles
}
&__tab {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
}
&__tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
padding: 0 6px;
border-radius: 50px;
background: var(--secondary);
font-size: 11px;
font-weight: var(--font-weight-normal);
line-height: 20px;
color: var(--foreground);
letter-spacing: -0.06px;
}
&__body {
flex: 1;
overflow-y: auto;
padding: var(--padding-5) var(--padding-4);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
&__footer {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--padding-4);
border-top: 1px solid var(--border);
background: var(--card);
}
&__footer-btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
padding: 0;
background: transparent;
border: none;
cursor: pointer;
font-family: Inter, sans-serif;
font-size: var(--label-small-400-font-size);
font-weight: var(--label-small-400-font-weight);
line-height: var(--label-small-400-line-height);
letter-spacing: var(--label-small-400-letter-spacing);
transition: opacity 0.15s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
opacity: 0.8;
}
&--danger {
color: var(--destructive);
}
&--primary {
color: var(--primary);
}
}
&__footer-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
// Field styles (mirrors EditMemberDrawer)
&__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
&__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: -0.07px;
cursor: default;
}
&__input {
height: 32px;
background: var(--l2-background);
border-color: var(--border);
color: var(--l1-foreground);
box-shadow: none;
&::placeholder {
color: var(--l3-foreground);
}
}
&__input-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 var(--padding-2);
border-radius: 2px;
background: var(--l2-background);
border: 1px solid var(--border);
&--disabled {
cursor: not-allowed;
opacity: 0.8;
}
}
&__input-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--foreground);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;
margin-left: 6px;
opacity: 0.6;
}
&__role-select {
width: 100%;
min-height: 32px;
.ant-select-selector {
background-color: var(--l2-background) !important;
border-color: var(--border) !important;
border-radius: 2px;
padding: 2px var(--padding-2) !important;
display: flex;
align-items: center;
flex-wrap: wrap;
min-height: 32px;
}
.ant-select-selection-overflow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 2px 0;
}
.ant-select-selection-overflow-item {
display: flex;
align-items: center;
}
.ant-select-selection-item {
display: flex;
align-items: center;
height: 22px;
font-size: var(--font-size-sm);
color: var(--l1-foreground);
background: var(--l3-background);
border: 1px solid var(--border);
border-radius: 2px;
padding: 0 4px 0 6px;
line-height: 20px;
letter-spacing: -0.07px;
margin: 0;
}
.ant-select-selection-item-remove {
display: flex;
align-items: center;
color: var(--foreground);
margin-left: 2px;
}
.ant-select-selection-placeholder {
font-size: var(--font-size-sm);
color: var(--l3-foreground);
}
.ant-select-arrow {
color: var(--foreground);
}
&:not(.ant-select-disabled):hover .ant-select-selector {
border-color: var(--foreground);
}
}
&__disabled-roles {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
&__meta {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
margin-top: var(--margin-1);
}
&__meta-item {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
&__meta-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
line-height: var(--line-height-20);
letter-spacing: 0.48px;
text-transform: uppercase;
}
}
// Keys tab styles
.keys-tab {
&__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-8) var(--padding-4);
gap: var(--spacing-4);
text-align: center;
}
&__empty-emoji {
font-size: 32px;
}
&__empty-text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
}
&__learn-more {
background: transparent;
border: none;
color: var(--primary);
font-size: var(--font-size-sm);
cursor: pointer;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__table-wrap {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
&__table-header {
display: flex;
align-items: center;
height: 32px;
padding: 0 var(--padding-4);
border-bottom: 1px solid var(--border);
background: transparent;
}
&__table-row {
display: flex;
align-items: center;
height: 38px;
padding: 0 var(--padding-4);
border-bottom: 1px solid var(--border);
cursor: pointer;
&:last-of-type {
border-bottom: none;
}
&:hover {
background: rgba(171, 189, 255, 0.04);
}
&--alt {
background: rgba(171, 189, 255, 0.02);
&:hover {
background: rgba(171, 189, 255, 0.04);
}
}
}
&__col-name {
flex: 1;
min-width: 0;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
}
&__col-expiry {
width: 100px;
text-align: right;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
flex-shrink: 0;
}
&__col-last-used {
width: 180px;
text-align: right;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--foreground);
letter-spacing: 0.48px;
text-transform: uppercase;
flex-shrink: 0;
}
&__col-action {
width: 40px;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
&__name-text {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--l1-foreground);
letter-spacing: normal;
text-transform: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Override col styles in data rows for non-header cells
&__table-row &__col-expiry,
&__table-row &__col-last-used {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
color: var(--l2-foreground);
letter-spacing: normal;
text-transform: none;
}
&__expiry--never {
color: var(--l2-foreground);
}
&__expiry--expired {
color: var(--l3-foreground);
}
&__revoke-btn {
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
&__table-footer {
display: flex;
align-items: center;
height: 36px;
padding: 0 var(--padding-4);
border-top: 1px solid var(--border);
}
&__count {
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
}
// Activate/disable confirm dialogs
.sa-activate-dialog,
.sa-disable-dialog {
background: var(--l2-background);
border: 1px solid var(--l2-border);
[data-slot='dialog-title'] {
color: var(--l1-foreground);
}
&__body {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l2-foreground);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.065px;
margin: 0;
strong {
font-weight: var(--font-weight-medium);
color: var(--l1-foreground);
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-4);
margin-top: var(--margin-6);
}
}

View File

@@ -0,0 +1,370 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@signozhq/button';
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
import { DrawerWrapper } from '@signozhq/drawer';
import {
Check,
Key,
LayoutGrid,
Plus,
PowerOff,
Trash2,
X,
} from '@signozhq/icons';
import { toast } from '@signozhq/sonner';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import {
useUpdateServiceAccount,
useUpdateServiceAccountStatus,
} from 'api/generated/services/serviceaccount';
import { useRoles } from 'components/RolesSelect';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import AddKeyModal from './AddKeyModal';
import KeysTab from './KeysTab';
import OverviewTab from './OverviewTab';
import './ServiceAccountDrawer.styles.scss';
export interface ServiceAccountDrawerProps {
account: ServiceAccountRow | null;
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
account,
open,
onClose,
onSuccess,
}: ServiceAccountDrawerProps): JSX.Element {
const [activeTab, setActiveTab] = useState<'overview' | 'keys'>('overview');
const [keyCount, setKeyCount] = useState(0);
const [isActivateConfirmOpen, setIsActivateConfirmOpen] = useState(false);
const [isDisableConfirmOpen, setIsDisableConfirmOpen] = useState(false);
const [localName, setLocalName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isAddKeyOpen, setIsAddKeyOpen] = useState(false);
useEffect(() => {
if (account) {
setLocalName(account.name ?? '');
setLocalRoles(account.roles ?? []);
setActiveTab('overview');
}
}, [account]);
const isDisabled = account?.status?.toUpperCase() !== 'ACTIVE';
const isDirty =
account !== null &&
(localName !== (account.name ?? '') ||
JSON.stringify(localRoles) !== JSON.stringify(account.roles ?? []));
const {
roles: availableRoles,
isLoading: rolesLoading,
isError: rolesError,
error: rolesErrorObj,
refetch: refetchRoles,
} = useRoles();
const { mutateAsync: updateAccount } = useUpdateServiceAccount();
const { mutateAsync: updateStatus } = useUpdateServiceAccountStatus();
const handleSave = useCallback(async (): Promise<void> => {
if (!account || !isDirty) {
return;
}
setIsSaving(true);
try {
await updateAccount({
pathParams: { id: account.id },
data: { name: localName, email: account.email, roles: localRoles },
});
toast.success('Service account updated successfully', { richColors: true });
onSuccess();
} catch {
toast.error('Failed to update service account', { richColors: true });
} finally {
setIsSaving(false);
}
}, [account, isDirty, localName, localRoles, updateAccount, onSuccess]);
const handleDisable = useCallback(async (): Promise<void> => {
if (!account) {
return;
}
setIsDisabling(true);
try {
await updateStatus({
pathParams: { id: account.id },
data: { status: 'DISABLED' },
});
toast.success('Service account disabled', { richColors: true });
onSuccess();
} catch {
toast.error('Failed to disable service account', { richColors: true });
} finally {
setIsDisabling(false);
}
}, [account, updateStatus, onSuccess]);
const handleActivate = useCallback(async (): Promise<void> => {
if (!account) {
return;
}
setIsActivating(true);
try {
await updateStatus({
pathParams: { id: account.id },
data: { status: 'ACTIVE' },
});
toast.success('Service account activated', { richColors: true });
setIsActivateConfirmOpen(false);
onSuccess();
} catch {
toast.error('Failed to activate service account', { richColors: true });
} finally {
setIsActivating(false);
}
}, [account, updateStatus, onSuccess]);
const handleClose = useCallback((): void => {
setIsActivateConfirmOpen(false);
setIsDisableConfirmOpen(false);
setIsAddKeyOpen(false);
onClose();
}, [onClose]);
const handleKeySuccess = useCallback((): void => {
// Keys tab will refetch internally; just update count if needed
}, []);
const drawerContent = (
<div className="sa-drawer__layout">
<div className="sa-drawer__tabs">
<ToggleGroup
type="single"
value={activeTab}
onValueChange={(val): void => {
if (val) {
setActiveTab(val as 'overview' | 'keys');
}
}}
className="sa-drawer__tab-group"
>
<ToggleGroupItem value="overview" className="sa-drawer__tab">
<LayoutGrid size={14} />
Overview
</ToggleGroupItem>
<ToggleGroupItem value="keys" className="sa-drawer__tab">
<Key size={14} />
Keys
{keyCount > 0 && <span className="sa-drawer__tab-count">{keyCount}</span>}
</ToggleGroupItem>
</ToggleGroup>
{activeTab === 'keys' && (
<Button
variant="solid"
size="sm"
color="primary"
onClick={(): void => setIsAddKeyOpen(true)}
>
<Plus size={12} />
Add Key
</Button>
)}
</div>
<div className="sa-drawer__body">
{activeTab === 'overview' && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={setLocalName}
localRoles={localRoles}
onRolesChange={setLocalRoles}
isDisabled={isDisabled}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
/>
)}
{activeTab === 'keys' && account && (
<KeysTab
accountId={account.id}
onKeyCountChange={setKeyCount}
onAddKeyClick={(): void => setIsAddKeyOpen(true)}
/>
)}
</div>
<div className="sa-drawer__footer">
{isDisabled ? (
<button
type="button"
className="sa-drawer__footer-btn sa-drawer__footer-btn--primary"
onClick={(): void => setIsActivateConfirmOpen(true)}
>
<Check size={12} />
Activate Service Account
</button>
) : (
<>
<button
type="button"
className="sa-drawer__footer-btn sa-drawer__footer-btn--danger"
onClick={(): void => setIsDisableConfirmOpen(true)}
>
<PowerOff size={12} />
Disable Service Account
</button>
{activeTab === 'overview' && (
<div className="sa-drawer__footer-right">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={handleClose}
>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={!isDirty || isSaving}
onClick={handleSave}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
</>
)}
</div>
</div>
);
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
type="panel"
showCloseButton
showOverlay={false}
allowOutsideClick
header={{ title: 'Service Account Details' }}
content={drawerContent}
className="sa-drawer"
/>
{/* Activate confirm dialog */}
<DialogWrapper
open={isActivateConfirmOpen}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setIsActivateConfirmOpen(false);
}
}}
title={`Activate service account ${account?.name ?? ''}?`}
width="narrow"
className="alert-dialog sa-activate-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="sa-activate-dialog__body">
Reactivating this service account will restore access for all its keys and
any systems using them.
</p>
<DialogFooter className="sa-activate-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setIsActivateConfirmOpen(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
size="sm"
disabled={isActivating}
onClick={handleActivate}
>
<Check size={12} />
{isActivating ? 'Activating...' : 'Activate'}
</Button>
</DialogFooter>
</DialogWrapper>
{/* Disable confirm dialog */}
<DialogWrapper
open={isDisableConfirmOpen}
onOpenChange={(isOpen): void => {
if (!isOpen) {
setIsDisableConfirmOpen(false);
}
}}
title={`Disable service account ${account?.name ?? ''}?`}
width="narrow"
className="alert-dialog delete-dialog"
showCloseButton={false}
disableOutsideClick={false}
>
<p className="delete-dialog__body">
Disabling this service account will revoke access for all its keys. Any
systems using this account will lose access immediately.
</p>
<DialogFooter className="delete-dialog__footer">
<Button
variant="solid"
color="secondary"
size="sm"
onClick={(): void => setIsDisableConfirmOpen(false)}
>
<X size={12} />
Cancel
</Button>
<Button
variant="solid"
color="destructive"
size="sm"
disabled={isDisabling}
onClick={handleDisable}
>
<Trash2 size={12} />
{isDisabling ? 'Disabling...' : 'Disable'}
</Button>
</DialogFooter>
</DialogWrapper>
{account && (
<AddKeyModal
open={isAddKeyOpen}
accountId={account.id}
onClose={(): void => setIsAddKeyOpen(false)}
onSuccess={handleKeySuccess}
/>
)}
</>
);
}
export default ServiceAccountDrawer;

View File

@@ -0,0 +1,18 @@
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
export function formatLastUsed(
lastUsed: Date | null | undefined,
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string,
): string {
if (!lastUsed) {
return '—';
}
const d = new Date(lastUsed);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(
d.toISOString(),
DATE_TIME_FORMATS.DASH_DATETIME,
);
}

View File

@@ -0,0 +1,207 @@
.sa-table-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border-radius: 4px;
}
.sa-table {
.ant-table {
background: transparent;
font-size: 13px;
}
.ant-table-container {
border-radius: 0 !important;
border: none !important;
}
.ant-table-thead {
> tr > th,
> tr > td {
background: var(--background);
font-size: var(--paragraph-small-600-font-size);
font-weight: var(--paragraph-small-600-font-weight);
line-height: var(--paragraph-small-600-line-height);
letter-spacing: 0.44px;
text-transform: uppercase;
color: var(--foreground);
padding: var(--padding-2) var(--padding-4);
border-bottom: none !important;
border-top: none !important;
&::before {
display: none !important;
}
}
}
.ant-table-tbody {
> tr > td {
border-bottom: none !important;
padding: var(--padding-2) var(--padding-4);
background: transparent;
transition: none;
}
> tr.sa-table-row--tinted > td {
background: rgba(171, 189, 255, 0.02);
}
> tr:hover > td {
background: rgba(171, 189, 255, 0.04) !important;
}
}
.ant-table-wrapper,
.ant-table-container,
.ant-spin-nested-loading,
.ant-spin-container {
border: none !important;
box-shadow: none !important;
}
.sa-status-cell {
[data-slot='badge'] {
padding: var(--padding-1) var(--padding-2);
align-items: center;
font-size: var(--uppercase-small-500-font-size);
font-weight: var(--uppercase-small-500-font-weight);
line-height: 100%;
letter-spacing: 0.44px;
text-transform: uppercase;
}
}
}
.sa-name-email-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
height: 22px;
overflow: hidden;
.sa-name {
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
color: var(--foreground);
line-height: var(--paragraph-base-500-line-height);
letter-spacing: -0.07px;
white-space: nowrap;
flex-shrink: 0;
}
.sa-email {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--l3-foreground-hover);
line-height: var(--paragraph-base-400-line-height);
letter-spacing: -0.07px;
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.sa-roles-cell {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.sa-dash {
font-size: var(--paragraph-base-400-font-size);
color: var(--l3-foreground-hover);
}
.sa-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--padding-12) var(--padding-4);
gap: var(--spacing-4);
color: var(--foreground);
&__emoji {
font-size: var(--font-size-2xl);
line-height: 1;
}
&__text {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
margin: 0;
line-height: var(--paragraph-base-400-font-height);
strong {
font-weight: var(--font-weight-medium);
color: var(--bg-base-white);
}
}
}
.sa-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: var(--padding-2) var(--padding-4);
.ant-pagination-total-text {
margin-right: auto;
}
.sa-pagination-range {
font-size: var(--font-size-xs);
color: var(--foreground);
}
.sa-pagination-total {
font-size: var(--font-size-xs);
color: var(--foreground);
opacity: 0.5;
}
}
.sa-tooltip {
.ant-tooltip-inner {
background-color: var(--bg-slate-500);
color: var(--foreground);
font-size: var(--font-size-xs);
line-height: normal;
padding: var(--padding-2) var(--padding-3);
border-radius: 4px;
text-align: left;
}
.ant-tooltip-arrow-content {
background-color: var(--bg-slate-500);
}
}
.lightMode {
.sa-table {
.ant-table-tbody {
> tr.sa-table-row--tinted > td {
background: rgba(0, 0, 0, 0.015);
}
> tr:hover > td {
background: rgba(0, 0, 0, 0.03) !important;
}
}
}
.sa-empty-state {
&__text {
strong {
color: var(--bg-base-black);
}
}
}
}

View File

@@ -0,0 +1,205 @@
import React from 'react';
import { Badge } from '@signozhq/badge';
import { Pagination, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { useTimezone } from 'providers/Timezone';
import './ServiceAccountsTable.styles.scss';
interface ServiceAccountsTableProps {
data: ServiceAccountRow[];
loading: boolean;
total: number;
currentPage: number;
pageSize: number;
searchQuery: string;
onPageChange: (page: number) => void;
onRowClick?: (row: ServiceAccountRow) => void;
}
function NameEmailCell({
name,
email,
}: {
name: string;
email: string;
}): JSX.Element {
return (
<div className="sa-name-email-cell">
{name && (
<span className="sa-name" title={name}>
{name}
</span>
)}
<Tooltip title={email} overlayClassName="sa-tooltip">
<span className="sa-email">{email}</span>
</Tooltip>
</div>
);
}
function RolesCell({ roles }: { roles: string[] }): JSX.Element {
if (!roles || roles.length === 0) {
return <span className="sa-dash"></span>;
}
const first = roles[0];
const overflow = roles.length - 1;
const tooltipContent = roles.slice(1).join(', ');
return (
<div className="sa-roles-cell">
<Badge color="vanilla">{first}</Badge>
{overflow > 0 && (
<Tooltip
title={tooltipContent}
overlayClassName="sa-tooltip"
overlayStyle={{ maxWidth: '600px' }}
>
<Badge color="vanilla" variant="outline" className="sa-status-badge">
+{overflow}
</Badge>
</Tooltip>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }): JSX.Element {
if (status?.toUpperCase() === 'ACTIVE') {
return (
<Badge color="forest" variant="outline">
ACTIVE
</Badge>
);
}
return (
<Badge color="vanilla" variant="outline" className="sa-status-badge">
DISABLED
</Badge>
);
}
function ServiceAccountsEmptyState({
searchQuery,
}: {
searchQuery: string;
}): JSX.Element {
return (
<div className="sa-empty-state">
<span className="sa-empty-state__emoji" role="img" aria-label="monocle face">
🧐
</span>
{searchQuery ? (
<p className="sa-empty-state__text">
No results for <strong>{searchQuery}</strong>
</p>
) : (
<p className="sa-empty-state__text">
No service accounts. Start by creating one to manage keys.
</p>
)}
</div>
);
}
function ServiceAccountsTable({
data,
loading,
total,
currentPage,
pageSize,
searchQuery,
onPageChange,
onRowClick,
}: ServiceAccountsTableProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formatCreatedAt = (date: string | null): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const columns: ColumnsType<ServiceAccountRow> = [
{
title: 'Name / Email',
dataIndex: 'name',
key: 'name',
render: (_, record): JSX.Element => (
<NameEmailCell name={record.name} email={record.email} />
),
},
{
title: 'Roles',
dataIndex: 'roles',
key: 'roles',
width: 420,
render: (roles: string[]): JSX.Element => <RolesCell roles={roles} />,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 120,
align: 'right' as const,
className: 'sa-status-cell',
render: (status: string): JSX.Element => <StatusBadge status={status} />,
},
];
const showPaginationTotal = (_total: number, range: number[]): JSX.Element => (
<>
<span className="sa-pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-pagination-total"> of {_total}</span>
</>
);
// Silence unused import warning — formatCreatedAt used by future columns
void formatCreatedAt;
return (
<div className="sa-table-wrapper">
<Table<ServiceAccountRow>
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={false}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'sa-table-row--tinted' : ''
}
showSorterTooltip={false}
locale={{
emptyText: <ServiceAccountsEmptyState searchQuery={searchQuery} />,
}}
className="sa-table"
onRow={(record): { onClick: () => void; style?: React.CSSProperties } => ({
onClick: (): void => onRowClick?.(record),
style: onRowClick ? { cursor: 'pointer' } : undefined,
})}
/>
{total > pageSize && (
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
showTotal={showPaginationTotal}
showSizeChanger={false}
onChange={onPageChange}
className="sa-table-pagination"
/>
)}
</div>
);
}
export default ServiceAccountsTable;

View File

@@ -0,0 +1,3 @@
export const DASHBOARD_CACHE_TIME = 30_000;
// keep it low or zero, otherwise, when enabled auto-refresh, this causes OOM due to accumulated queries in cache
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;

View File

@@ -96,4 +96,7 @@ export const REACT_QUERY_KEY = {
// Span Percentiles Query Keys
GET_SPAN_PERCENTILES: 'GET_SPAN_PERCENTILES',
// Dashboard Grid Card Query Keys
DASHBOARD_GRID_CARD_QUERY_RANGE: 'DASHBOARD_GRID_CARD_QUERY_RANGE',
} as const;

View File

@@ -86,6 +86,7 @@ const ROUTES = {
METER_EXPLORER_VIEWS: '/meter/explorer/views',
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
} as const;
export default ROUTES;

View File

@@ -13,6 +13,10 @@ import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import {
@@ -101,9 +105,10 @@ function DynamicVariableInput({
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { maxTime, minTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
variableFetchCycleId,
@@ -232,6 +237,9 @@ function DynamicVariableInput({
!!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
queryFn: ({ signal }) =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'

View File

@@ -11,6 +11,10 @@ import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
@@ -33,9 +37,10 @@ function QueryVariableInput({
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { maxTime, minTime, isAutoRefreshDisabled } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
variableFetchCycleId,
@@ -197,6 +202,9 @@ function QueryVariableInput({
signal,
),
refetchOnWindowFocus: false,
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onSuccess: (response) => {
getOptions(response.payload);
settleVariableFetch(variableData.name, 'complete');

View File

@@ -25,6 +25,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../../constants/queryCacheTime';
import { REACT_QUERY_KEY } from '../../../constants/reactQueryKeys';
import EmptyWidget from '../EmptyWidget';
import { MenuItemKeys } from '../WidgetHeader/contants';
import { GridCardGraphProps } from './types';
@@ -68,10 +73,12 @@ function GridCardGraph({
setDashboardQueryRangeCalled,
} = useDashboard();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const {
minTime,
maxTime,
selectedTime: globalSelectedInterval,
isAutoRefreshDisabled,
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
const handleBackNavigation = (): void => {
const searchParams = new URLSearchParams(window.location.search);
@@ -210,8 +217,10 @@ function GridCardGraph({
version || DEFAULT_ENTITY_VERSION,
{
queryKey: [
REACT_QUERY_KEY.DASHBOARD_GRID_CARD_QUERY_RANGE,
maxTime,
minTime,
isAutoRefreshDisabled,
globalSelectedInterval,
widget?.query,
widget?.panelTypes,
@@ -241,6 +250,9 @@ function GridCardGraph({
return failureCount < 2;
},
keepPreviousData: true,
cacheTime: isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
enabled: queryEnabledCondition,
refetchOnMount: false,
onError: (error) => {

View File

@@ -119,7 +119,7 @@ function MembersSettings(): JSX.Element {
return;
}
const maxPage = Math.ceil(filteredMembers.length / PAGE_SIZE);
if (currentPage > maxPage) {
if (currentPage > maxPage || currentPage < 1) {
setPage(maxPage);
}
}, [filteredMembers.length, currentPage, setPage]);

View File

@@ -11,7 +11,7 @@ import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader';
import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal';
import { InviteMemberFormValues } from 'container/OrganizationSettings/PendingInvitesContainer';
import { InviteMemberFormValues } from 'container/OrganizationSettings/utils';
import history from 'lib/history';
import { UserPlus } from 'lucide-react';
import { useAppContext } from 'providers/App/App';

View File

@@ -12,7 +12,7 @@ import { SOMETHING_WENT_WRONG } from 'constants/api';
import { FeatureKeys } from 'constants/features';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/PendingInvitesContainer';
import { InviteTeamMembersProps } from 'container/OrganizationSettings/utils';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { useAppContext } from 'providers/App/App';

View File

@@ -1,32 +0,0 @@
import { gold } from '@ant-design/colors';
import { ExclamationCircleTwoTone } from '@ant-design/icons';
import { Space, Typography } from 'antd';
function DeleteMembersDetails({
name,
}: DeleteMembersDetailsProps): JSX.Element {
return (
<div>
<Space direction="horizontal" size="middle" align="start">
<ExclamationCircleTwoTone
twoToneColor={[gold[6], '#1f1f1f']}
style={{
fontSize: '1.4rem',
}}
/>
<Space direction="vertical">
<Typography>Are you sure you want to delete {name}</Typography>
<Typography>
This will remove all access from dashboards and other features in SigNoz
</Typography>
</Space>
</Space>
</div>
);
}
interface DeleteMembersDetailsProps {
name: string;
}
export default DeleteMembersDetails;

View File

@@ -1,167 +0,0 @@
import {
ChangeEventHandler,
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import { CopyOutlined } from '@ant-design/icons';
import { Button, Input, Select, Space, Tooltip } from 'antd';
import getResetPasswordToken from 'api/v1/factor_password/getResetPasswordToken';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import { InputGroup, SelectDrawer, Title } from './styles';
const { Option } = Select;
function EditMembersDetails({
emailAddress,
name,
role,
setEmailAddress,
setName,
setRole,
id,
}: EditMembersDetailsProps): JSX.Element {
const [passwordLink, setPasswordLink] = useState<string>('');
const { t } = useTranslation(['common']);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [state, copyToClipboard] = useCopyToClipboard();
const getPasswordLink = (token: string): string =>
`${window.location.origin}${ROUTES.PASSWORD_RESET}?token=${token}`;
const onChangeHandler = useCallback(
(setFunc: Dispatch<SetStateAction<string>>, value: string) => {
setFunc(value);
},
[],
);
const { notifications } = useNotifications();
useEffect(() => {
if (state.error) {
notifications.error({
message: t('something_went_wrong'),
});
}
if (state.value) {
notifications.success({
message: t('success'),
});
}
}, [state.error, state.value, t, notifications]);
const onPasswordChangeHandler: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setPasswordLink(event.target.value);
},
[],
);
const onGeneratePasswordHandler = async (): Promise<void> => {
try {
setIsLoading(true);
const response = await getResetPasswordToken({
userId: id || '',
});
setPasswordLink(getPasswordLink(response.data.token));
setIsLoading(false);
} catch (error) {
setIsLoading(false);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
return (
<Space direction="vertical" size="large">
<Space direction="horizontal">
<Title>Email address</Title>
<Input
placeholder="john@signoz.io"
readOnly
onChange={(event): void =>
onChangeHandler(setEmailAddress, event.target.value)
}
disabled={isLoading}
value={emailAddress}
/>
</Space>
<Space direction="horizontal">
<Title>Name (optional)</Title>
<Input
placeholder="John"
onChange={(event): void => onChangeHandler(setName, event.target.value)}
value={name}
disabled={isLoading}
/>
</Space>
<Space direction="horizontal">
<Title>Role</Title>
<SelectDrawer
value={role}
onSelect={(value: unknown): void => {
if (typeof value === 'string') {
setRole(value as ROLES);
}
}}
disabled={isLoading}
>
<Option value="ADMIN">ADMIN</Option>
<Option value="VIEWER">VIEWER</Option>
<Option value="EDITOR">EDITOR</Option>
</SelectDrawer>
</Space>
<Button
loading={isLoading}
disabled={isLoading}
onClick={onGeneratePasswordHandler}
type="primary"
>
Generate Reset Password link
</Button>
{passwordLink && (
<InputGroup>
<Input
style={{ width: '100%' }}
defaultValue="git@github.com:ant-design/ant-design.git"
onChange={onPasswordChangeHandler}
value={passwordLink}
disabled={isLoading}
/>
<Tooltip title="COPY LINK">
<Button
icon={<CopyOutlined />}
onClick={(): void => copyToClipboard(passwordLink)}
/>
</Tooltip>
</InputGroup>
)}
</Space>
);
}
interface EditMembersDetailsProps {
emailAddress: string;
name: string;
role: ROLES;
setEmailAddress: Dispatch<SetStateAction<string>>;
setName: Dispatch<SetStateAction<string>>;
setRole: Dispatch<SetStateAction<ROLES>>;
id: string;
}
export default EditMembersDetails;

View File

@@ -1,16 +0,0 @@
import { Select, Typography } from 'antd';
import styled from 'styled-components';
export const SelectDrawer = styled(Select)`
width: 120px;
`;
export const Title = styled(Typography)`
width: 7rem;
`;
export const InputGroup = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`;

View File

@@ -11,7 +11,7 @@ import {
} from 'antd';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { InviteMemberFormValues } from '../PendingInvitesContainer/index';
import { InviteMemberFormValues } from '../utils';
import { SelectDrawer, SpaceContainer, TitleWrapper } from './styles';
function InviteTeamMembers({ form, onFinish }: Props): JSX.Element {

View File

@@ -6,7 +6,7 @@ import { useNotifications } from 'hooks/useNotifications';
import APIError from 'types/api/error';
import InviteTeamMembers from '../InviteTeamMembers';
import { InviteMemberFormValues } from '../PendingInvitesContainer';
import { InviteMemberFormValues } from '../utils';
export interface InviteUserModalProps {
isInviteTeamMemberModalOpen: boolean;

View File

@@ -1,324 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import {
Button,
Modal,
Space,
TableColumnsType as ColumnsType,
Typography,
} from 'antd';
import getAll from 'api/v1/user/get';
import deleteUser from 'api/v1/user/id/delete';
import update from 'api/v1/user/id/update';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { ResizeTable } from 'components/ResizeTable';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { ROLES } from 'types/roles';
import DeleteMembersDetails from '../DeleteMembersDetails';
import EditMembersDetails from '../EditMembersDetails';
function UserFunction({
setDataSource,
accessLevel,
name,
email,
id,
}: UserFunctionProps): JSX.Element {
const [isModalVisible, setIsModalVisible] = useState(false);
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const onModalToggleHandler = (
func: Dispatch<SetStateAction<boolean>>,
value: boolean,
): void => {
func(value);
};
const [emailAddress, setEmailAddress] = useState(email);
const [updatedName, setUpdatedName] = useState(name);
const [role, setRole] = useState<ROLES>(accessLevel);
const { t } = useTranslation(['common']);
const [isDeleteLoading, setIsDeleteLoading] = useState<boolean>(false);
const [isUpdateLoading, setIsUpdateLoading] = useState<boolean>(false);
const { notifications } = useNotifications();
const onUpdateDetailsHandler = (): void => {
setDataSource((data) => {
const index = data.findIndex((e) => e.id === id);
if (index !== -1) {
const current = data[index];
const updatedData: DataType[] = [
...data.slice(0, index),
{
...current,
name: updatedName,
accessLevel: role,
email: emailAddress,
},
...data.slice(index + 1, data.length),
];
return updatedData;
}
return data;
});
};
const onDelete = (): void => {
setDataSource((source) => {
const index = source.findIndex((e) => e.id === id);
if (index !== -1) {
const updatedData: DataType[] = [
...source.slice(0, index),
...source.slice(index + 1, source.length),
];
return updatedData;
}
return source;
});
};
const onDeleteHandler = async (): Promise<void> => {
try {
setIsDeleteLoading(true);
await deleteUser({
userId: id,
});
onDelete();
notifications.success({
message: t('success', {
ns: 'common',
}),
});
setIsDeleteModalVisible(false);
setIsDeleteLoading(false);
} catch (error) {
setIsDeleteLoading(false);
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
const onEditMemberDetails = async (): Promise<void> => {
try {
setIsUpdateLoading(true);
await update({
userId: id,
displayName: updatedName,
role,
});
onUpdateDetailsHandler();
if (role !== accessLevel) {
notifications.success({
message: 'User details updated successfully',
description: 'The user details have been updated successfully.',
});
} else {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
}
setIsUpdateLoading(false);
setIsModalVisible(false);
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
setIsUpdateLoading(false);
}
};
return (
<>
<Space direction="horizontal">
<Typography.Link
onClick={(): void => onModalToggleHandler(setIsModalVisible, true)}
>
Edit
</Typography.Link>
<Typography.Link
onClick={(): void => onModalToggleHandler(setIsDeleteModalVisible, true)}
>
Delete
</Typography.Link>
</Space>
<Modal
title="Edit member details"
className="edit-member-details-modal"
open={isModalVisible}
onOk={(): void => onModalToggleHandler(setIsModalVisible, false)}
onCancel={(): void => onModalToggleHandler(setIsModalVisible, false)}
centered
destroyOnClose
footer={[
<Button
key="back"
onClick={(): void => onModalToggleHandler(setIsModalVisible, false)}
type="default"
>
Cancel
</Button>,
<Button
key="Invite_team_members"
onClick={onEditMemberDetails}
type="primary"
disabled={isUpdateLoading}
loading={isUpdateLoading}
>
Update Details
</Button>,
]}
>
<EditMembersDetails
{...{
emailAddress,
name: updatedName,
role,
setEmailAddress,
setName: setUpdatedName,
setRole,
id,
}}
/>
</Modal>
<Modal
title="Edit member details"
open={isDeleteModalVisible}
onOk={onDeleteHandler}
onCancel={(): void => onModalToggleHandler(setIsDeleteModalVisible, false)}
centered
confirmLoading={isDeleteLoading}
>
<DeleteMembersDetails name={name} />
</Modal>
</>
);
}
function Members(): JSX.Element {
const { org } = useAppContext();
const { data, isLoading, error } = useQuery({
queryFn: () => getAll(),
queryKey: ['getOrgUser', org?.[0].id],
});
const [dataSource, setDataSource] = useState<DataType[]>([]);
useEffect(() => {
if (data?.data && Array.isArray(data.data)) {
const updatedData: DataType[] = data?.data?.map((e) => ({
accessLevel: e.role,
email: e.email,
id: String(e.id),
joinedOn: String(e.createdAt),
name: e.displayName,
}));
setDataSource(updatedData);
}
}, [data]);
const columns: ColumnsType<DataType> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: 'Emails',
dataIndex: 'email',
key: 'email',
width: 100,
},
{
title: 'Access Level',
dataIndex: 'accessLevel',
key: 'accessLevel',
width: 50,
},
{
title: 'Joined On',
dataIndex: 'joinedOn',
key: 'joinedOn',
width: 60,
render: (_, record): JSX.Element => {
const { joinedOn } = record;
return (
<Typography>
{dayjs(joinedOn).format(DATE_TIME_FORMATS.MONTH_DATE_FULL)}
</Typography>
);
},
},
{
title: 'Action',
dataIndex: 'action',
width: 80,
render: (_, record): JSX.Element => (
<UserFunction
{...{
accessLevel: record.accessLevel,
email: record.email,
joinedOn: record.joinedOn,
name: record.name,
id: record.id,
setDataSource,
}}
/>
),
},
];
return (
<div className="members-container">
<Typography.Title level={3}>
Members{' '}
{!isLoading && dataSource && (
<div className="members-count"> ({dataSource.length}) </div>
)}
</Typography.Title>
{!(error as APIError) && (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={dataSource}
pagination={false}
loading={isLoading}
bordered
/>
)}
{(error as APIError) && <ErrorContent error={error as APIError} />}
</div>
);
}
interface DataType {
id: string;
name: string;
email: string;
accessLevel: ROLES;
joinedOn: string;
}
interface UserFunctionProps extends DataType {
setDataSource: Dispatch<SetStateAction<DataType[]>>;
}
export default Members;

View File

@@ -1,248 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { PlusOutlined } from '@ant-design/icons';
import {
Button,
Form,
Space,
TableColumnsType as ColumnsType,
Typography,
} from 'antd';
import get from 'api/v1/invite/get';
import deleteInvite from 'api/v1/invite/id/delete';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { ResizeTable } from 'components/ResizeTable';
import { INVITE_MEMBERS_HASH } from 'constants/app';
import ROUTES from 'constants/routes';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import APIError from 'types/api/error';
import { PendingInvite } from 'types/api/user/getPendingInvites';
import { ROLES } from 'types/roles';
import InviteUserModal from '../InviteUserModal/InviteUserModal';
import { TitleWrapper } from './styles';
function PendingInvitesContainer(): JSX.Element {
const [
isInviteTeamMemberModalOpen,
setIsInviteTeamMemberModalOpen,
] = useState<boolean>(false);
const [form] = Form.useForm<InviteMemberFormValues>();
const { t } = useTranslation(['organizationsettings', 'common']);
const [state, setText] = useCopyToClipboard();
const { notifications } = useNotifications();
const { user } = useAppContext();
useEffect(() => {
if (state.error) {
notifications.error({
message: state.error.message,
});
}
if (state.value) {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
}
}, [state.error, state.value, t, notifications]);
const { data, isLoading, error, isError, refetch } = useQuery({
queryFn: get,
queryKey: ['getPendingInvites', user?.accessJwt],
});
const [dataSource, setDataSource] = useState<DataProps[]>([]);
const toggleModal = useCallback(
(value: boolean): void => {
setIsInviteTeamMemberModalOpen(value);
if (!value) {
form.resetFields();
}
},
[form],
);
const { hash } = useLocation();
const getParsedInviteData = useCallback(
(payload: PendingInvite[] = []) =>
payload?.map((data) => ({
key: data.createdAt,
name: data.name,
id: data.id,
email: data.email,
accessLevel: data.role,
inviteLink: `${window.location.origin}${ROUTES.SIGN_UP}?token=${data.token}`,
})),
[],
);
useEffect(() => {
if (hash === INVITE_MEMBERS_HASH) {
toggleModal(true);
}
}, [hash, toggleModal]);
useEffect(() => {
if (data?.data) {
const parsedData = getParsedInviteData(data?.data || []);
setDataSource(parsedData);
}
}, [data, getParsedInviteData]);
const onRevokeHandler = async (id: string): Promise<void> => {
try {
await deleteInvite({
id,
});
// remove from the client data
const index = dataSource.findIndex((e) => e.id === id);
if (index !== -1) {
setDataSource([
...dataSource.slice(0, index),
...dataSource.slice(index + 1, dataSource.length),
]);
}
notifications.success({
message: t('success', {
ns: 'common',
}),
});
} catch (error) {
notifications.error({
message: (error as APIError).getErrorCode(),
description: (error as APIError).getErrorMessage(),
});
}
};
const columns: ColumnsType<DataProps> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
width: 100,
},
{
title: 'Emails',
dataIndex: 'email',
key: 'email',
width: 80,
},
{
title: 'Access Level',
dataIndex: 'accessLevel',
key: 'accessLevel',
width: 50,
},
{
title: 'Invite Link',
dataIndex: 'inviteLink',
key: 'Invite Link',
ellipsis: true,
width: 100,
},
{
title: 'Action',
dataIndex: 'action',
width: 80,
key: 'Action',
render: (_, record): JSX.Element => (
<Space direction="horizontal">
<Typography.Link onClick={(): Promise<void> => onRevokeHandler(record.id)}>
Revoke
</Typography.Link>
<Typography.Link
onClick={(): void => {
setText(record.inviteLink);
}}
>
Copy Invite Link
</Typography.Link>
</Space>
),
},
];
return (
<div className="pending-invites-container-wrapper">
<InviteUserModal
form={form}
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
toggleModal={toggleModal}
onClose={refetch}
/>
<div className="pending-invites-container">
<TitleWrapper>
<Typography.Title level={3}>
{t('pending_invites')}
{dataSource && (
<div className="members-count"> ({dataSource.length})</div>
)}
</Typography.Title>
<Space>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={(): void => {
toggleModal(true);
}}
>
{t('invite_members')}
</Button>
</Space>
</TitleWrapper>
{!isError && (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={dataSource}
pagination={false}
loading={isLoading}
bordered
/>
)}
{isError && <ErrorContent error={error as APIError} />}
</div>
</div>
);
}
export interface InviteTeamMembersProps {
email: string;
name: string;
role: string;
id: string;
frontendBaseUrl: string;
}
interface DataProps {
key: number;
name: string;
id: string;
email: string;
accessLevel: ROLES;
inviteLink: string;
}
type Role = 'ADMIN' | 'VIEWER' | 'EDITOR';
export interface InviteMemberFormValues {
members: {
email: string;
name: string;
role: Role;
}[];
}
export default PendingInvitesContainer;

View File

@@ -1,8 +0,0 @@
import styled from 'styled-components';
export const TitleWrapper = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`;

View File

@@ -3,8 +3,6 @@ import { useAppContext } from 'providers/App/App';
import AuthDomain from './AuthDomain';
import DisplayName from './DisplayName';
import Members from './Members';
import PendingInvitesContainer from './PendingInvitesContainer';
import './OrganizationSettings.styles.scss';
@@ -23,9 +21,6 @@ function OrganizationSettings(): JSX.Element {
))}
</Space>
<PendingInvitesContainer />
<Members />
<AuthDomain />
</div>
);

View File

@@ -1,37 +0,0 @@
import { act, render, screen, waitFor } from 'tests/test-utils';
import Members from '../Members';
describe('Organization Settings Page', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('render list of members', async () => {
act(() => {
render(<Members />);
});
const title = await screen.findByText(/Members/i);
expect(title).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('firstUser@test.io')).toBeInTheDocument(); // first item
expect(screen.getByText('lastUser@test.io')).toBeInTheDocument(); // last item
});
});
// this is required as our edit/delete logic is dependent on the index and it will break with pagination enabled
it('render list of members without pagination', async () => {
render(<Members />);
await waitFor(() => {
expect(screen.getByText('firstUser@test.io')).toBeInTheDocument(); // first item
expect(screen.getByText('lastUser@test.io')).toBeInTheDocument(); // last item
expect(
document.querySelector('.ant-table-pagination'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,17 @@
export interface InviteTeamMembersProps {
email: string;
name: string;
role: string;
id: string;
frontendBaseUrl: string;
}
type Role = 'ADMIN' | 'VIEWER' | 'EDITOR';
export interface InviteMemberFormValues {
members: {
email: string;
name: string;
role: Role;
}[];
}

View File

@@ -100,6 +100,17 @@ interface QueryBuilderSearchV2Props {
// Determines whether to call onChange when a tag is closed
triggerOnChangeOnClose?: boolean;
skipQueryBuilderRedirect?: boolean;
/** Additional props passed through to the underlying Ant Design Select (e.g. listHeight, listItemHeight) */
selectProps?: Partial<
Pick<
React.ComponentProps<typeof Select>,
| 'listHeight'
| 'listItemHeight'
| 'popupClassName'
| 'dropdownMatchSelectWidth'
| 'popupMatchSelectWidth'
>
>;
}
export interface Option {
@@ -142,6 +153,7 @@ function QueryBuilderSearchV2(
hideSpanScopeSelector,
triggerOnChangeOnClose,
skipQueryBuilderRedirect,
selectProps,
} = props;
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
@@ -972,6 +984,7 @@ function QueryBuilderSearchV2(
return (
<div className="query-builder-search-v2">
<Select
{...selectProps}
data-testid={'qb-search-select'}
ref={selectRef}
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
@@ -1077,6 +1090,7 @@ QueryBuilderSearchV2.defaultProps = {
hideSpanScopeSelector: true,
triggerOnChangeOnClose: false,
skipQueryBuilderRedirect: false,
selectProps: undefined,
};
export default QueryBuilderSearchV2;

View File

@@ -0,0 +1,134 @@
.sa-settings {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--padding-4) var(--padding-2) var(--padding-6) var(--padding-4);
height: 100%;
&__header {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
&__title {
font-size: var(--label-large-500-font-size);
font-weight: var(--label-large-500-font-weight);
color: var(--text-base-white);
letter-spacing: -0.09px;
line-height: var(--line-height-normal);
margin: 0;
}
&__subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: -0.07px;
line-height: var(--paragraph-base-400-line-height);
margin: 0;
}
&__learn-more {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
&__controls {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
&__search {
flex: 1;
min-width: 0;
}
}
.sa-status-badge {
color: var(--l3-foreground);
border-color: var(--border);
}
.sa-settings-filter-trigger {
display: flex;
align-items: center;
gap: var(--spacing-2);
border: 1px solid var(--border);
border-radius: 2px;
background-color: var(--l2-background);
> span {
color: var(--foreground);
}
&__chevron {
flex-shrink: 0;
color: var(--foreground);
}
}
.sa-settings-filter-dropdown {
.ant-dropdown-menu {
padding: var(--padding-3) 14px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--l2-background);
backdrop-filter: blur(20px);
}
.ant-dropdown-menu-item {
background: transparent !important;
padding: var(--padding-1) 0 !important;
&:hover {
background: transparent !important;
}
}
}
.sa-settings-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
color: var(--foreground);
letter-spacing: 0.14px;
min-width: 170px;
&:hover {
color: var(--card-foreground);
background: transparent;
}
}
.sa-settings-search-input {
height: 32px;
color: var(--l1-foreground);
background-color: var(--l2-background);
border-color: var(--border);
&::placeholder {
color: var(--l3-foreground);
}
}
.lightMode {
.sa-settings {
&__title {
color: var(--text-base-black);
}
}
.sa-settings-filter-option {
&:hover {
color: var(--bg-neutral-light-100);
}
}
}

View File

@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/button';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
import ServiceAccountsTable from 'components/ServiceAccountsTable/ServiceAccountsTable';
import useUrlQuery from 'hooks/useUrlQuery';
import { FilterMode, ServiceAccountRow, ServiceAccountStatus } from './utils';
import './ServiceAccountsSettings.styles.scss';
const PAGE_SIZE = 20;
function ServiceAccountsSettings(): JSX.Element {
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<FilterMode>(FilterMode.All);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [
selectedAccount,
setSelectedAccount,
] = useState<ServiceAccountRow | null>(null);
const {
data: serviceAccountsData,
isLoading,
refetch,
} = useListServiceAccounts();
const allAccounts = useMemo(
(): ServiceAccountRow[] =>
(serviceAccountsData?.data ?? []).map((sa) => ({
id: sa.id,
name: sa.name,
email: sa.email,
roles: sa.roles,
status: sa.status,
createdAt: sa.createdAt ? String(sa.createdAt) : null,
updatedAt: sa.updatedAt ? String(sa.updatedAt) : null,
})),
[serviceAccountsData],
);
const activeCount = useMemo(
() =>
allAccounts.filter(
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
).length,
[allAccounts],
);
const disabledCount = useMemo(
() =>
allAccounts.filter(
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
).length,
[allAccounts],
);
const filteredAccounts = useMemo((): ServiceAccountRow[] => {
let result = allAccounts;
if (filterMode === FilterMode.Active) {
result = result.filter(
(a) => a.status?.toUpperCase() === ServiceAccountStatus.Active,
);
} else if (filterMode === FilterMode.Disabled) {
result = result.filter(
(a) => a.status?.toUpperCase() !== ServiceAccountStatus.Active,
);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(a) =>
a.name?.toLowerCase().includes(q) ||
a.email?.toLowerCase().includes(q) ||
a.roles?.some((role: string) => role.toLowerCase().includes(q)),
);
}
return result;
}, [allAccounts, filterMode, searchQuery]);
const paginatedAccounts = useMemo((): ServiceAccountRow[] => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredAccounts.slice(start, start + PAGE_SIZE);
}, [filteredAccounts, currentPage]);
const setPage = useCallback(
(page: number): void => {
urlQuery.set('page', String(page));
history.replace({ search: urlQuery.toString() });
},
[history, urlQuery],
);
useEffect(() => {
if (filteredAccounts.length === 0) {
return;
}
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
if (currentPage > maxPage || currentPage < 1) {
setPage(maxPage);
}
}, [filteredAccounts.length, currentPage, setPage]);
const totalCount = allAccounts.length;
const filterMenuItems: MenuProps['items'] = [
{
key: FilterMode.All,
label: (
<div className="sa-settings-filter-option">
<span>All accounts {totalCount}</span>
{filterMode === FilterMode.All && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.All);
setPage(1);
},
},
{
key: FilterMode.Active,
label: (
<div className="sa-settings-filter-option">
<span>Active {activeCount}</span>
{filterMode === FilterMode.Active && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Active);
setPage(1);
},
},
{
key: FilterMode.Disabled,
label: (
<div className="sa-settings-filter-option">
<span>Disabled {disabledCount}</span>
{filterMode === FilterMode.Disabled && <Check size={14} />}
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Disabled);
setPage(1);
},
},
];
const filterLabel =
filterMode === FilterMode.Active
? `Active ⎯ ${activeCount}`
: filterMode === FilterMode.Disabled
? `Disabled ⎯ ${disabledCount}`
: `All accounts ⎯ ${totalCount}`;
const handleRowClick = useCallback((row: ServiceAccountRow): void => {
setSelectedAccount(row);
}, []);
const handleDrawerClose = useCallback((): void => {
setSelectedAccount(null);
}, []);
const handleDrawerSuccess = useCallback((): void => {
refetch();
setSelectedAccount(null);
}, [refetch]);
const handleCreateSuccess = useCallback((): void => {
refetch();
}, [refetch]);
return (
<>
<div className="sa-settings">
<div className="sa-settings__header">
<h1 className="sa-settings__title">Service Accounts</h1>
<p className="sa-settings__subtitle">
Service accounts are used for machine-to-machine authentication via API
keys. {/* Todo: to add doc links */}
{/* <a
href="https://signoz.io/docs/service-accounts"
target="_blank"
rel="noopener noreferrer"
className="sa-settings__learn-more"
>
Learn more
</a> */}
</p>
</div>
<div className="sa-settings__controls">
<Dropdown
menu={{ items: filterMenuItems }}
trigger={['click']}
overlayClassName="sa-settings-filter-dropdown"
>
<Button
variant="solid"
size="sm"
color="secondary"
className="sa-settings-filter-trigger"
>
<span>{filterLabel}</span>
<ChevronDown size={12} className="sa-settings-filter-trigger__chevron" />
</Button>
</Dropdown>
<div className="sa-settings__search">
<Input
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
setPage(1);
}}
className="sa-settings-search-input"
color="secondary"
/>
</div>
<Button
variant="solid"
size="sm"
color="primary"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={12} />
New Service Account
</Button>
</div>
</div>
<ServiceAccountsTable
data={paginatedAccounts}
loading={isLoading}
total={filteredAccounts.length}
currentPage={currentPage}
pageSize={PAGE_SIZE}
searchQuery={searchQuery}
onPageChange={setPage}
onRowClick={handleRowClick}
/>
<CreateServiceAccountModal
open={isCreateModalOpen}
onClose={(): void => setIsCreateModalOpen(false)}
onSuccess={handleCreateSuccess}
/>
<ServiceAccountDrawer
account={selectedAccount}
open={selectedAccount !== null}
onClose={handleDrawerClose}
onSuccess={handleDrawerSuccess}
/>
</>
);
}
export default ServiceAccountsSettings;

View File

@@ -0,0 +1,20 @@
export enum FilterMode {
All = 'all',
Active = 'active',
Disabled = 'disabled',
}
export enum ServiceAccountStatus {
Active = 'ACTIVE',
Disabled = 'DISABLED',
}
export interface ServiceAccountRow {
id: string;
name: string;
email: string;
roles: string[];
status: ServiceAccountStatus | string;
createdAt: string | null;
updatedAt: string | null;
}

View File

@@ -8,6 +8,7 @@ import {
BellDot,
Binoculars,
Book,
Bot,
Boxes,
BugIcon,
Building2,
@@ -358,6 +359,13 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: false,
itemKey: 'members',
},
{
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
label: 'Service Accounts',
icon: <Bot size={16} />,
isEnabled: false,
itemKey: 'service-accounts',
},
{
key: ROUTES.API_KEYS,
label: 'API Keys',

View File

@@ -154,6 +154,7 @@ export const routesToSkip = [
ROUTES.ALL_DASHBOARD,
ROUTES.ORG_SETTINGS,
ROUTES.MEMBERS_SETTINGS,
ROUTES.SERVICE_ACCOUNTS_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.API_KEYS,
ROUTES.ERROR_DETAIL,

View File

@@ -160,6 +160,7 @@ function Filters({
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
/>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">

View File

@@ -11,8 +11,17 @@ export default {
name: 'dashboards',
type: 'metaresources',
},
{
name: 'role',
type: 'role',
},
{
name: 'roles',
type: 'metaresources',
},
],
relations: {
assignee: ['role'],
create: ['metaresources'],
delete: ['user', 'role', 'organization', 'metaresource'],
list: ['metaresources'],

View File

@@ -0,0 +1 @@
export { default } from 'container/ServiceAccountsSettings/ServiceAccountsSettings';

View File

@@ -84,6 +84,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.INGESTION_SETTINGS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.SHORTCUTS
? true
: item.isEnabled,
@@ -115,6 +116,7 @@ function SettingsPage(): JSX.Element {
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS ||
item.key === ROUTES.INGESTION_SETTINGS
? true
: item.isEnabled,
@@ -140,7 +142,8 @@ function SettingsPage(): JSX.Element {
isEnabled:
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
item.key === ROUTES.MEMBERS_SETTINGS
item.key === ROUTES.MEMBERS_SETTINGS ||
item.key === ROUTES.SERVICE_ACCOUNTS_SETTINGS
? true
: item.isEnabled,
}));

View File

@@ -17,6 +17,7 @@ import { TFunction } from 'i18next';
import {
Backpack,
BellDot,
Bot,
Building,
Cpu,
CreditCard,
@@ -30,6 +31,7 @@ import {
} from 'lucide-react';
import ChannelsEdit from 'pages/ChannelsEdit';
import MembersSettings from 'pages/MembersSettings';
import ServiceAccountsSettings from 'pages/ServiceAccountsSettings';
import Shortcuts from 'pages/Shortcuts';
export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
@@ -203,6 +205,22 @@ export const mySettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const serviceAccountsSettings = (
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_t: TFunction,
): RouteTabProps['routes'] => [
{
Component: ServiceAccountsSettings,
name: (
<div className="periscope-tab">
<Bot size={16} /> Service Accounts
</div>
),
route: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
key: ROUTES.SERVICE_ACCOUNTS_SETTINGS,
},
];
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: (): JSX.Element => (

View File

@@ -17,6 +17,7 @@ import {
organizationSettings,
roleDetails,
rolesSettings,
serviceAccountsSettings,
} from './config';
export const getRoutes = (
@@ -61,7 +62,11 @@ export const getRoutes = (
settings.push(...alertChannels(t));
if (isAdmin) {
settings.push(...apiKeys(t), ...membersSettings(t));
settings.push(
...apiKeys(t),
...membersSettings(t),
...serviceAccountsSettings(t),
);
}
// todo: Sagar - check the condition for role list and details page, to whom we want to serve

View File

@@ -46,6 +46,10 @@ import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as generateUUID } from 'uuid';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../constants/queryCacheTime';
import { useDashboardVariablesSelector } from '../../hooks/dashboard/useDashboardVariables';
import {
setDashboardVariablesStore,
@@ -272,7 +276,12 @@ export function DashboardProvider({
return data;
};
const dashboardResponse = useQuery(
[REACT_QUERY_KEY.DASHBOARD_BY_ID, isDashboardPage?.params, dashboardId],
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
isDashboardPage?.params,
dashboardId,
globalTime.isAutoRefreshDisabled,
],
{
enabled: (!!isDashboardPage || !!isDashboardWidgetPage) && isLoggedIn,
queryFn: async () => {
@@ -289,6 +298,9 @@ export function DashboardProvider({
}
},
refetchOnWindowFocus: false,
cacheTime: globalTime.isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onError: (error) => {
showErrorModal(error as APIError);
},

View File

@@ -1,7 +1,10 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
import getDashboard from 'api/v1/dashboards/id/get';
import { DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { DashboardProvider, useDashboard } from 'providers/Dashboard/Dashboard';
@@ -47,6 +50,7 @@ jest.mock('react-redux', () => ({
selectedTime: 'GLOBAL_TIME',
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-01T01:00:00Z',
isAutoRefreshDisabled: true,
})),
useDispatch: jest.fn(() => jest.fn()),
}));
@@ -322,13 +326,68 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
REACT_QUERY_KEY.DASHBOARD_BY_ID,
{ dashboardId: dashboardId1 },
dashboardId1,
true, // globalTime.isAutoRefreshDisabled
]);
expect(cacheKeys[1]).toEqual([
REACT_QUERY_KEY.DASHBOARD_BY_ID,
{ dashboardId: dashboardId2 },
dashboardId2,
true, // globalTime.isAutoRefreshDisabled
]);
});
it('should not store dashboard in cache when autoRefresh is enabled (isAutoRefreshDisabled=false)', async () => {
jest.mocked(useSelector).mockImplementation(() => ({
selectedTime: 'GLOBAL_TIME',
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-01T01:00:00Z',
isAutoRefreshDisabled: false,
}));
const queryClient = createTestQueryClient();
const dashboardId = 'auto-refresh-dashboard';
mockUseRouteMatch.mockReturnValue({
path: ROUTES.DASHBOARD,
url: `/dashboard/${dashboardId}`,
isExact: true,
params: { dashboardId },
});
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/dashboard/${dashboardId}`]}>
<DashboardProvider>
<TestComponent />
</DashboardProvider>
</MemoryRouter>
</QueryClientProvider>,
);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId });
});
const dashboardQuery = queryClient
.getQueryCache()
.getAll()
.find(
(query) =>
query.queryKey[0] === REACT_QUERY_KEY.DASHBOARD_BY_ID &&
query.queryKey[3] === false,
);
expect(dashboardQuery).toBeDefined();
expect((dashboardQuery as { cacheTime: number }).cacheTime).toBe(
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
);
jest.mocked(useSelector).mockImplementation(() => ({
selectedTime: 'GLOBAL_TIME',
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-01T01:00:00Z',
isAutoRefreshDisabled: true,
}));
});
});
});

View File

@@ -100,6 +100,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
ROLES_SETTINGS: ['ADMIN'],
ROLE_DETAILS: ['ADMIN'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -32,7 +32,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Tags: []string{"users"},
Summary: "Create bulk invite",
Description: "This endpoint creates a bulk invite for a user",
Request: make([]*types.PostableInvite, 0),
Request: new(types.PostableBulkInviteRequest),
RequestContentType: "application/json",
Response: nil,
SuccessStatusCode: http.StatusCreated,

View File

@@ -17,7 +17,7 @@ func NewStore(sqlstore sqlstore.SQLStore) authtypes.AuthNStore {
return &store{sqlstore: sqlstore}
}
func (store *store) GetUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error) {
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error) {
user := new(types.User)
factorPassword := new(types.FactorPassword)
@@ -28,6 +28,7 @@ func (store *store) GetUserAndFactorPasswordByEmailAndOrgID(ctx context.Context,
Model(user).
Where("email = ?", email).
Where("org_id = ?", orgID).
Where("status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with email %s in org %s not found", email, orgID)

View File

@@ -21,7 +21,7 @@ func New(store authtypes.AuthNStore) *AuthN {
}
func (a *AuthN) Authenticate(ctx context.Context, email string, password string, orgID valuer.UUID) (*authtypes.Identity, error) {
user, factorPassword, err := a.store.GetUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
user, factorPassword, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}

View File

@@ -796,17 +796,17 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
}
opts := querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: m.logger,
FieldMapper: m.fieldMapper,
ConditionBuilder: m.condBuilder,
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
FieldKeys: keys,
StartNs: querybuilder.ToNanoSecs(uint64(startMillis)),
EndNs: querybuilder.ToNanoSecs(uint64(endMillis)),
}
whereClause, err := querybuilder.PrepareWhereClause(expression, opts)
startNs := querybuilder.ToNanoSecs(uint64(startMillis))
endNs := querybuilder.ToNanoSecs(uint64(endMillis))
whereClause, err := querybuilder.PrepareWhereClause(expression, opts, startNs, endNs)
if err != nil {
return nil, err
}

View File

@@ -65,6 +65,9 @@ func (module *module) GetSessionContext(ctx context.Context, email valuer.Email,
return nil, err
}
// filter out deleted users
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.ErrIfDeleted() != nil })
// Since email is a valuer, we can be sure that it is a valid email and we can split it to get the domain name.
name := strings.Split(email.String(), "@")[1]
@@ -141,7 +144,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
roleMapping := authDomain.AuthDomainConfig().RoleMapping
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, role, callbackIdentity.OrgID)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, role, callbackIdentity.OrgID, types.UserStatusActive)
if err != nil {
return "", err
}

View File

@@ -86,6 +86,15 @@ func (module *getter) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int6
return count, nil
}
func (module *getter) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error) {
counts, err := module.store.CountByOrgIDAndStatuses(ctx, orgID, statuses)
if err != nil {
return nil, err
}
return counts, nil
}
func (module *getter) GetFactorPasswordByUserID(ctx context.Context, userID valuer.UUID) (*types.FactorPassword, error) {
factorPassword, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {

View File

@@ -149,16 +149,11 @@ func (h *handler) DeleteInvite(w http.ResponseWriter, r *http.Request) {
return
}
uuid, err := valuer.NewUUID(id)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
return
}
if err := h.module.DeleteInvite(ctx, claims.OrgID, uuid); err != nil {
if err := h.module.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.UserID); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}
@@ -218,6 +213,9 @@ func (h *handler) ListUsers(w http.ResponseWriter, r *http.Request) {
return
}
// temp code - show only active users
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusActive })
render.Success(w, http.StatusOK, users)
}

View File

@@ -2,7 +2,6 @@ package impluser
import (
"context"
"fmt"
"slices"
"strings"
"time"
@@ -52,39 +51,50 @@ func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing em
}
func (m *Module) AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) {
invite, err := m.store.GetInviteByToken(ctx, token)
// get the user by reset password token
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
user, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.OrgID)
// update the password and delete the token
err = m.UpdatePasswordByResetPasswordToken(ctx, token, password)
if err != nil {
return nil, err
}
factorPassword, err := types.NewFactorPassword(password, user.ID.StringValue())
// query the user again
user, err = m.store.GetByOrgIDAndID(ctx, user.OrgID, user.ID)
if err != nil {
return nil, err
}
err = m.CreateUser(ctx, user, root.WithFactorPassword(factorPassword))
if err != nil {
return nil, err
}
if err := m.DeleteInvite(ctx, invite.OrgID.String(), invite.ID); err != nil {
return nil, err
}
return user, nil
}
func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) {
invite, err := m.store.GetInviteByToken(ctx, token)
// get the user
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
// create a dummy invite obj for backward compatibility
invite := &types.Invite{
Identifiable: types.Identifiable{
ID: user.ID,
},
Name: user.DisplayName,
Email: user.Email,
Token: token,
Role: user.Role,
OrgID: user.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
}
return invite, nil
}
@@ -95,80 +105,158 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
return nil, err
}
invites := make([]*types.Invite, 0, len(bulkInvites.Invites))
for _, invite := range bulkInvites.Invites {
// check if user exists
existingUser, err := m.store.GetUserByEmailAndOrgID(ctx, invite.Email, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingUser != nil {
if err := existingUser.ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot send invite to root user")
}
}
if existingUser != nil {
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with the same email")
}
// Check if an invite already exists
existingInvite, err := m.store.GetInviteByEmailAndOrgID(ctx, invite.Email, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingInvite != nil {
return nil, errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email")
}
role, err := types.NewRole(invite.Role.String())
if err != nil {
return nil, err
}
newInvite, err := types.NewInvite(invite.Name, role, orgID, invite.Email)
if err != nil {
return nil, err
}
newInvite.InviteLink = fmt.Sprintf("%s/signup?token=%s", invite.FrontendBaseUrl, newInvite.Token)
invites = append(invites, newInvite)
// validate all emails to be invited
emails := make([]string, len(bulkInvites.Invites))
for idx, invite := range bulkInvites.Invites {
emails[idx] = invite.Email.StringValue()
}
err = m.store.CreateBulkInvite(ctx, invites)
users, err := m.store.GetUsersByEmailsOrgIDAndStatuses(ctx, orgID, emails, []string{types.UserStatusActive.StringValue(), types.UserStatusPendingInvite.StringValue()})
if err != nil {
return nil, err
}
for i := 0; i < len(invites); i++ {
m.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{"invitee_email": invites[i].Email, "invitee_role": invites[i].Role})
if len(users) > 0 {
if err := users[0].ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "Cannot send invite to root user")
}
// if the frontend base url is not provided, we don't send the email
if bulkInvites.Invites[i].FrontendBaseUrl == "" {
m.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", "invitee_email", invites[i].Email)
if users[0].Status == types.UserStatusPendingInvite {
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email: %s", users[0].Email.StringValue())
}
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with this email: %s", users[0].Email.StringValue())
}
type userWithResetToken struct {
User *types.User
ResetPasswordToken *types.ResetPasswordToken
}
newUsersWithResetToken := make([]*userWithResetToken, len(bulkInvites.Invites))
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
for idx, invite := range bulkInvites.Invites {
role, err := types.NewRole(invite.Role.String())
if err != nil {
return err
}
// create a new user with pending invite status
newUser, err := types.NewUser(invite.Name, invite.Email, role, orgID, types.UserStatusPendingInvite)
if err != nil {
return err
}
// store the user and password in db
err = m.createUserWithoutGrant(ctx, newUser)
if err != nil {
return err
}
// generate reset password token
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, newUser.ID)
if err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to create reset password token for invited user", "error", err)
return err
}
newUsersWithResetToken[idx] = &userWithResetToken{
User: newUser,
ResetPasswordToken: resetPasswordToken,
}
}
return nil
}); err != nil {
return nil, err
}
invites := make([]*types.Invite, len(bulkInvites.Invites))
// send password reset emails to all the invited users
for idx, userWithToken := range newUsersWithResetToken {
m.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{
"invitee_email": userWithToken.User.Email,
"invitee_role": userWithToken.User.Role,
})
invite := &types.Invite{
Identifiable: types.Identifiable{
ID: userWithToken.User.ID,
},
Name: userWithToken.User.DisplayName,
Email: userWithToken.User.Email,
Token: userWithToken.ResetPasswordToken.Token,
Role: userWithToken.User.Role,
OrgID: userWithToken.User.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: userWithToken.User.CreatedAt,
UpdatedAt: userWithToken.User.UpdatedAt,
},
}
invites[idx] = invite
frontendBaseUrl := bulkInvites.Invites[idx].FrontendBaseUrl
if frontendBaseUrl == "" {
m.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", "invitee_email", userWithToken.User.Email)
continue
}
if err := m.emailing.SendHTML(ctx, invites[i].Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"inviter_email": creator.Email,
"link": fmt.Sprintf("%s/signup?token=%s", bulkInvites.Invites[i].FrontendBaseUrl, invites[i].Token),
}); err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to send email", "error", err)
}
resetLink := userWithToken.ResetPasswordToken.FactorPasswordResetLink(frontendBaseUrl)
tokenLifetime := m.config.Password.Reset.MaxTokenLifetime
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
if err := m.emailing.SendHTML(ctx, userWithToken.User.Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"inviter_email": creator.Email,
"link": resetLink,
"Expiry": humanizedTokenLifetime,
}); err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to send invite email", "error", err)
}
}
return invites, nil
}
func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
return m.store.ListInvite(ctx, orgID)
}
// find all the users with pending_invite status
users, err := m.store.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID))
if err != nil {
return nil, err
}
func (m *Module) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error {
return m.store.DeleteInvite(ctx, orgID, id)
pendingUsers := slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusPendingInvite })
var invites []*types.Invite
for _, pUser := range pendingUsers {
// get the reset password token
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, pUser.ID)
if err != nil {
return nil, err
}
// create a dummy invite obj for backward compatibility
invite := &types.Invite{
Identifiable: types.Identifiable{
ID: pUser.ID,
},
Name: pUser.DisplayName,
Email: pUser.Email,
Token: resetPasswordToken.Token,
Role: pUser.Role,
OrgID: pUser.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: pUser.CreatedAt,
UpdatedAt: pUser.UpdatedAt, // dummy
},
}
invites = append(invites, invite)
}
return invites, nil
}
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
@@ -213,6 +301,14 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
return nil, errors.WithAdditionalf(err, "cannot update root user")
}
if err := existingUser.ErrIfDeleted(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot update deleted user")
}
if err := existingUser.ErrIfPending(); err != nil {
return nil, errors.WithAdditionalf(err, "cannot update pending user")
}
requestor, err := m.store.GetUser(ctx, valuer.MustNewUUID(updatedBy))
if err != nil {
return nil, err
@@ -224,7 +320,7 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
// Make sure that the request is not demoting the last admin user.
if user.Role != "" && user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin {
adminUsers, err := m.store.GetUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
adminUsers, err := m.store.GetActiveUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
if err != nil {
return nil, err
}
@@ -280,12 +376,16 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return errors.WithAdditionalf(err, "cannot delete root user")
}
if err := user.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "cannot delete already deleted user")
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(user.Email.String())) {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "integration user cannot be deleted")
}
// don't allow to delete the last admin user
adminUsers, err := module.store.GetUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
adminUsers, err := module.store.GetActiveUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
if err != nil {
return err
}
@@ -300,7 +400,8 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
if err := module.store.DeleteUser(ctx, orgID.String(), user.ID.StringValue()); err != nil {
// for now we are only soft deleting users
if err := module.store.SoftDeleteUser(ctx, orgID.String(), user.ID.StringValue()); err != nil {
return err
}
@@ -321,6 +422,10 @@ func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID
return nil, errors.WithAdditionalf(err, "cannot reset password for root user")
}
if err := user.ErrIfDeleted(); err != nil {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "user has been deleted")
}
password, err := module.store.GetPasswordByUserID(ctx, userID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
@@ -375,7 +480,7 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported, "Users are not allowed to reset their password themselves, please contact an admin to reset your password.")
}
user, err := module.store.GetUserByEmailAndOrgID(ctx, email, orgID)
user, err := module.GetNonDeletedUserByEmailAndOrgID(ctx, email, orgID)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
return nil // for security reasons
@@ -393,7 +498,7 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
return err
}
resetLink := fmt.Sprintf("%s/password-reset?token=%s", frontendBaseURL, token.Token)
resetLink := token.FactorPasswordResetLink(frontendBaseURL)
tokenLifetime := module.config.Password.Reset.MaxTokenLifetime
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
@@ -435,6 +540,11 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
// handle deleted user
if err := user.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "deleted users cannot reset their password")
}
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot reset password for root user")
}
@@ -443,7 +553,38 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
return module.store.UpdatePassword(ctx, password)
// since grant is idempotent, multiple calls won't cause issues in case of retries
if user.Status == types.UserStatusPendingInvite {
if err = module.authz.Grant(
ctx,
user.OrgID,
[]string{roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil),
); err != nil {
return err
}
}
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if user.Status == types.UserStatusPendingInvite {
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
if err := module.store.UpdateUser(ctx, user.OrgID, user); err != nil {
return err
}
}
if err := module.store.UpdatePassword(ctx, password); err != nil {
return err
}
if err := module.store.DeleteResetPasswordTokenByPasswordID(ctx, password.ID); err != nil {
return err
}
return nil
})
}
func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, oldpasswd string, passwd string) error {
@@ -452,6 +593,10 @@ func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, ol
return err
}
if err := user.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for deleted user")
}
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for root user")
}
@@ -469,7 +614,17 @@ func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, ol
return err
}
if err := module.store.UpdatePassword(ctx, password); err != nil {
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.UpdatePassword(ctx, password); err != nil {
return err
}
if err := module.store.DeleteResetPasswordTokenByPasswordID(ctx, password.ID); err != nil {
return err
}
return nil
}); err != nil {
return err
}
@@ -477,7 +632,7 @@ func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, ol
}
func (module *Module) GetOrCreateUser(ctx context.Context, user *types.User, opts ...root.CreateUserOption) (*types.User, error) {
existingUser, err := module.store.GetUserByEmailAndOrgID(ctx, user.Email, user.OrgID)
existingUser, err := module.GetNonDeletedUserByEmailAndOrgID(ctx, user.Email, user.OrgID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
@@ -485,6 +640,16 @@ func (module *Module) GetOrCreateUser(ctx context.Context, user *types.User, opt
}
if existingUser != nil {
// for users logging through SSO flow but are having status as pending_invite
if existingUser.Status == types.UserStatusPendingInvite {
// respect the role coming from the SSO
existingUser.Update("", user.Role)
// activate the user
if err = module.activatePendingUser(ctx, existingUser); err != nil {
return nil, err
}
}
return existingUser, nil
}
@@ -561,12 +726,15 @@ func (module *Module) CreateFirstUser(ctx context.Context, organization *types.O
func (module *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
count, err := module.store.CountByOrgID(ctx, orgID)
counts, err := module.store.CountByOrgIDAndStatuses(ctx, orgID, []string{types.UserStatusActive.StringValue(), types.UserStatusDeleted.StringValue(), types.UserStatusPendingInvite.StringValue()})
if err == nil {
stats["user.count"] = count
stats["user.count"] = counts[types.UserStatusActive] + counts[types.UserStatusDeleted] + counts[types.UserStatusPendingInvite]
stats["user.count.active"] = counts[types.UserStatusActive]
stats["user.count.deleted"] = counts[types.UserStatusDeleted]
stats["user.count.pending_invite"] = counts[types.UserStatusPendingInvite]
}
count, err = module.store.CountAPIKeyByOrgID(ctx, orgID)
count, err := module.store.CountAPIKeyByOrgID(ctx, orgID)
if err == nil {
stats["factor.api_key.count"] = count
}
@@ -574,6 +742,28 @@ func (module *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
return stats, nil
}
// this function restricts that only one non-deleted user email can exist for an org ID, if found more, it throws an error
func (module *Module) GetNonDeletedUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error) {
existingUsers, err := module.store.GetUsersByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
// filter out the deleted users
existingUsers = slices.DeleteFunc(existingUsers, func(user *types.User) bool { return user.ErrIfDeleted() != nil })
if len(existingUsers) > 1 {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "Multiple non-deleted users found for email %s in org_id: %s", email.StringValue(), orgID.StringValue())
}
if len(existingUsers) == 1 {
return existingUsers[0], nil
}
return nil, errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "No non-deleted user found with email %s in org_id: %s", email.StringValue(), orgID.StringValue())
}
func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
@@ -598,3 +788,25 @@ func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.U
return nil
}
func (module *Module) activatePendingUser(ctx context.Context, user *types.User) error {
err := module.authz.Grant(
ctx,
user.OrgID,
[]string{roletypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil),
)
if err != nil {
return err
}
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
err = module.store.UpdateUser(ctx, user.OrgID, user)
if err != nil {
return err
}
return nil
}

View File

@@ -143,7 +143,7 @@ func (s *service) reconcileRootUser(ctx context.Context, orgID valuer.UUID) erro
}
func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID) error {
existingUser, err := s.store.GetUserByEmailAndOrgID(ctx, s.config.Email, orgID)
existingUser, err := s.module.GetNonDeletedUserByEmailAndOrgID(ctx, s.config.Email, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}

View File

@@ -25,77 +25,6 @@ func NewStore(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings) typ
return &store{sqlstore: sqlstore, settings: settings}
}
// CreateBulkInvite implements types.InviteStore.
func (store *store) CreateBulkInvite(ctx context.Context, invites []*types.Invite) error {
_, err := store.sqlstore.BunDB().NewInsert().
Model(&invites).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, types.ErrInviteAlreadyExists, "invite with email: %s already exists in org: %s", invites[0].Email, invites[0].OrgID)
}
return nil
}
// Delete implements types.InviteStore.
func (store *store) DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error {
_, err := store.sqlstore.BunDB().NewDelete().
Model(&types.Invite{}).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with id: %s does not exist in org: %s", id.StringValue(), orgID)
}
return nil
}
func (store *store) GetInviteByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.Invite, error) {
invite := new(types.Invite)
err := store.
sqlstore.
BunDBCtx(ctx).NewSelect().
Model(invite).
Where("email = ?", email).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with email %s does not exist in org %s", email, orgID)
}
return invite, nil
}
func (store *store) GetInviteByToken(ctx context.Context, token string) (*types.GettableInvite, error) {
invite := new(types.Invite)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(invite).
Where("token = ?", token).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite does not exist", token)
}
return invite, nil
}
func (store *store) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
invites := new([]*types.Invite)
err := store.sqlstore.BunDB().NewSelect().
Model(invites).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrInviteNotFound, "invite with org id: %s does not exist", orgID)
}
return *invites, nil
}
func (store *store) CreatePassword(ctx context.Context, password *types.FactorPassword) error {
_, err := store.
sqlstore.
@@ -175,24 +104,25 @@ func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id v
return user, nil
}
func (store *store) GetUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error) {
user := new(types.User)
func (store *store) GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*types.User, error) {
var users []*types.User
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
Model(&users).
Where("org_id = ?", orgID).
Where("email = ?", email).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with email %s does not exist in org %s", email, orgID)
return nil, err
}
return user, nil
return users, nil
}
func (store *store) GetUsersByRoleAndOrgID(ctx context.Context, role types.Role, orgID valuer.UUID) ([]*types.User, error) {
func (store *store) GetActiveUsersByRoleAndOrgID(ctx context.Context, role types.Role, orgID valuer.UUID) ([]*types.User, error) {
var users []*types.User
err := store.
@@ -202,6 +132,7 @@ func (store *store) GetUsersByRoleAndOrgID(ctx context.Context, role types.Role,
Model(&users).
Where("org_id = ?", orgID).
Where("role = ?", role).
Where("status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, err
@@ -221,6 +152,7 @@ func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *typ
Column("role").
Column("is_root").
Column("updated_at").
Column("status").
Where("org_id = ?", orgID).
Where("id = ?", user.ID).
Exec(ctx)
@@ -331,10 +263,98 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
return nil
}
func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string) error {
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
}
defer func() {
_ = tx.Rollback()
}()
// get the password id
var password types.FactorPassword
err = tx.NewSelect().
Model(&password).
Where("user_id = ?", id).
Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete password")
}
// delete reset password request
_, err = tx.NewDelete().
Model(new(types.ResetPasswordToken)).
Where("password_id = ?", password.ID.String()).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete reset password request")
}
// delete factor password
_, err = tx.NewDelete().
Model(new(types.FactorPassword)).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete factor password")
}
// delete api keys
_, err = tx.NewDelete().
Model(&types.StorableAPIKey{}).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete API keys")
}
// delete user_preference
_, err = tx.NewDelete().
Model(new(preferencetypes.StorableUserPreference)).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user preferences")
}
// delete tokens
_, err = tx.NewDelete().
Model(new(authtypes.StorableToken)).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete tokens")
}
// soft delete user
now := time.Now()
_, err = tx.NewUpdate().
Model(new(types.User)).
Set("status = ?", types.UserStatusDeleted).
Set("deleted_at = ?", now).
Set("updated_at = ?", now).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user")
}
err = tx.Commit()
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction")
}
return nil
}
func (store *store) CreateResetPasswordToken(ctx context.Context, resetPasswordToken *types.ResetPasswordToken) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewInsert().
Model(resetPasswordToken).
Exec(ctx)
@@ -367,7 +387,7 @@ func (store *store) GetPasswordByUserID(ctx context.Context, userID valuer.UUID)
err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(password).
Where("user_id = ?", userID).
@@ -383,7 +403,7 @@ func (store *store) GetResetPasswordTokenByPasswordID(ctx context.Context, passw
err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewSelect().
Model(resetPasswordToken).
Where("password_id = ?", passwordID).
@@ -396,7 +416,7 @@ func (store *store) GetResetPasswordTokenByPasswordID(ctx context.Context, passw
}
func (store *store) DeleteResetPasswordTokenByPasswordID(ctx context.Context, passwordID valuer.UUID) error {
_, err := store.sqlstore.BunDB().NewDelete().
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().
Model(&types.ResetPasswordToken{}).
Where("password_id = ?", passwordID).
Exec(ctx)
@@ -418,23 +438,14 @@ func (store *store) GetResetPasswordToken(ctx context.Context, token string) (*t
Where("token = ?", token).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token does not exist", token)
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrResetPasswordTokenNotFound, "reset password token does not exist")
}
return resetPasswordRequest, nil
}
func (store *store) UpdatePassword(ctx context.Context, factorPassword *types.FactorPassword) error {
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
_, err = tx.
_, err := store.sqlstore.BunDBCtx(ctx).
NewUpdate().
Model(factorPassword).
Where("user_id = ?", factorPassword.UserID).
@@ -443,20 +454,6 @@ func (store *store) UpdatePassword(ctx context.Context, factorPassword *types.Fa
return store.sqlstore.WrapNotFoundErrf(err, types.ErrPasswordNotFound, "password for user %s does not exist", factorPassword.UserID)
}
_, err = tx.
NewDelete().
Model(&types.ResetPasswordToken{}).
Where("password_id = ?", factorPassword.ID).
Exec(ctx)
if err != nil {
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
@@ -582,6 +579,36 @@ func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64,
return int64(count), nil
}
func (store *store) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error) {
user := new(types.User)
var results []struct {
Status valuer.String `bun:"status"`
Count int64 `bun:"count"`
}
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
ColumnExpr("status").
ColumnExpr("COUNT(*) AS count").
Where("org_id = ?", orgID.StringValue()).
Where("status IN (?)", bun.In(statuses)).
GroupExpr("status").
Scan(ctx, &results)
if err != nil {
return nil, err
}
counts := make(map[valuer.String]int64, len(results))
for _, r := range results {
counts[r.Status] = r.Count
}
return counts, nil
}
func (store *store) CountAPIKeyByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
apiKey := new(types.StorableAPIKey)
@@ -638,3 +665,41 @@ func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.
return users, nil
}
func (store *store) GetUserByResetPasswordToken(ctx context.Context, token string) (*types.User, error) {
user := new(types.User)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
Join(`JOIN factor_password ON factor_password.user_id = "user".id`).
Join("JOIN reset_password_token ON reset_password_token.password_id = factor_password.id").
Where("reset_password_token.token = ?", token).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user not found for reset password token")
}
return user, nil
}
func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, emails []string, statuses []string) ([]*types.User, error) {
users := []*types.User{}
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&users).
Where("email IN (?)", bun.In(emails)).
Where("org_id = ?", orgID).
Where("status in (?)", bun.In(statuses)).
Scan(ctx)
if err != nil {
return nil, err
}
return users, nil
}

View File

@@ -42,7 +42,6 @@ type Module interface {
// invite
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error)
DeleteInvite(ctx context.Context, orgID string, id valuer.UUID) error
AcceptInvite(ctx context.Context, token string, password string) (*types.User, error)
GetInviteByToken(ctx context.Context, token string) (*types.Invite, error)
@@ -53,6 +52,8 @@ type Module interface {
RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UUID) error
GetAPIKey(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.StorableAPIKeyUser, error)
GetNonDeletedUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error)
statsreporter.StatsCollector
}
@@ -78,6 +79,9 @@ type Getter interface {
// Count users by org id.
CountByOrgID(context.Context, valuer.UUID) (int64, error)
// Count of users by org id and grouped by status.
CountByOrgIDAndStatuses(context.Context, valuer.UUID, []string) (map[valuer.String]int64, error)
// Get factor password by user id.
GetFactorPasswordByUserID(context.Context, valuer.UUID) (*types.FactorPassword, error)
}

View File

@@ -66,7 +66,6 @@ func newProvider(
telemetrylogs.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
)
// Create trace statement builder

View File

@@ -48,8 +48,6 @@ func NewAggExprRewriter(
// and the args if the parametric aggregation function is used.
func (r *aggExprRewriter) Rewrite(
ctx context.Context,
startNs uint64,
endNs uint64,
expr string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
@@ -76,12 +74,7 @@ func (r *aggExprRewriter) Rewrite(
return "", nil, errors.NewInternalf(errors.CodeInternal, "no SELECT items for %q", expr)
}
visitor := newExprVisitor(
ctx,
startNs,
endNs,
r.logger,
keys,
visitor := newExprVisitor(r.logger, keys,
r.fullTextColumn,
r.fieldMapper,
r.conditionBuilder,
@@ -101,8 +94,6 @@ func (r *aggExprRewriter) Rewrite(
// RewriteMulti rewrites a slice of expressions.
func (r *aggExprRewriter) RewriteMulti(
ctx context.Context,
startNs uint64,
endNs uint64,
exprs []string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
@@ -111,7 +102,7 @@ func (r *aggExprRewriter) RewriteMulti(
var errs []error
var chArgsList [][]any
for i, e := range exprs {
w, chArgs, err := r.Rewrite(ctx, startNs, endNs, e, rateInterval, keys)
w, chArgs, err := r.Rewrite(ctx, e, rateInterval, keys)
if err != nil {
errs = append(errs, err)
out[i] = e
@@ -128,9 +119,6 @@ func (r *aggExprRewriter) RewriteMulti(
// exprVisitor walks FunctionExpr nodes and applies the mappers.
type exprVisitor struct {
ctx context.Context
startNs uint64
endNs uint64
chparser.DefaultASTVisitor
logger *slog.Logger
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
@@ -144,9 +132,6 @@ type exprVisitor struct {
}
func newExprVisitor(
ctx context.Context,
startNs uint64,
endNs uint64,
logger *slog.Logger,
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey,
fullTextColumn *telemetrytypes.TelemetryFieldKey,
@@ -155,9 +140,6 @@ func newExprVisitor(
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
) *exprVisitor {
return &exprVisitor{
ctx: ctx,
startNs: startNs,
endNs: endNs,
logger: logger,
fieldKeys: fieldKeys,
fullTextColumn: fullTextColumn,
@@ -204,16 +186,13 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
whereClause, err := PrepareWhereClause(
origPred,
FilterExprVisitorOpts{
Context: v.ctx,
Logger: v.logger,
FieldKeys: v.fieldKeys,
FieldMapper: v.fieldMapper,
ConditionBuilder: v.conditionBuilder,
FullTextColumn: v.fullTextColumn,
JsonKeyToKey: v.jsonKeyToKey,
StartNs: v.startNs,
EndNs: v.endNs,
},
}, 0, 0,
)
if err != nil {
return err
@@ -233,7 +212,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
for i := 0; i < len(args)-1; i++ {
origVal := args[i].String()
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(origVal)
expr, exprArgs, err := CollisionHandledFinalExpr(v.ctx, v.startNs, v.endNs, &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey)
expr, exprArgs, err := CollisionHandledFinalExpr(context.Background(), &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey)
if err != nil {
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to get table field name for %q", origVal)
}
@@ -251,7 +230,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
for i, arg := range args {
orig := arg.String()
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(orig)
expr, exprArgs, err := CollisionHandledFinalExpr(v.ctx, v.startNs, v.endNs, &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey)
expr, exprArgs, err := CollisionHandledFinalExpr(context.Background(), &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey)
if err != nil {
return err
}

View File

@@ -147,7 +147,12 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
// So we can safely override the context and data type
actions = append(actions, fmt.Sprintf("Overriding key: %s to %s", key, intrinsicOrCalculatedField))
key.OverrideMetadataFrom(intrinsicOrCalculatedField)
key.FieldContext = intrinsicOrCalculatedField.FieldContext
key.FieldDataType = intrinsicOrCalculatedField.FieldDataType
key.JSONDataType = intrinsicOrCalculatedField.JSONDataType
key.Indexes = intrinsicOrCalculatedField.Indexes
key.Materialized = intrinsicOrCalculatedField.Materialized
key.JSONPlan = intrinsicOrCalculatedField.JSONPlan
return actions
}
@@ -193,9 +198,13 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
if !key.Equal(matchingKey) {
actions = append(actions, fmt.Sprintf("Adjusting key %s to %s", key, matchingKey))
}
key.Name = matchingKey.Name
key.OverrideMetadataFrom(matchingKey)
key.FieldContext = matchingKey.FieldContext
key.FieldDataType = matchingKey.FieldDataType
key.JSONDataType = matchingKey.JSONDataType
key.Indexes = matchingKey.Indexes
key.Materialized = matchingKey.Materialized
key.JSONPlan = matchingKey.JSONPlan
return actions
} else {

View File

@@ -19,8 +19,6 @@ import (
func CollisionHandledFinalExpr(
ctx context.Context,
startNs uint64,
endNs uint64,
field *telemetrytypes.TelemetryFieldKey,
fm qbtypes.FieldMapper,
cb qbtypes.ConditionBuilder,
@@ -46,7 +44,7 @@ func CollisionHandledFinalExpr(
addCondition := func(key *telemetrytypes.TelemetryFieldKey) error {
sb := sqlbuilder.NewSelectBuilder()
condition, err := cb.ConditionFor(ctx, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
condition, err := cb.ConditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb, 0, 0)
if err != nil {
return err
}
@@ -59,7 +57,7 @@ func CollisionHandledFinalExpr(
return nil
}
fieldExpression, fieldForErr := fm.FieldFor(ctx, startNs, endNs, field)
colName, fieldForErr := fm.FieldFor(ctx, field)
if errors.Is(fieldForErr, qbtypes.ErrColumnNotFound) {
// the key didn't have the right context to be added to the query
// we try to use the context we know of
@@ -94,9 +92,9 @@ func CollisionHandledFinalExpr(
if err != nil {
return "", nil, err
}
fieldExpression, _ = fm.FieldFor(ctx, startNs, endNs, key)
fieldExpression, _ = DataTypeCollisionHandledFieldName(key, dummyValue, fieldExpression, qbtypes.FilterOperatorUnknown)
stmts = append(stmts, fieldExpression)
colName, _ = fm.FieldFor(ctx, key)
colName, _ = DataTypeCollisionHandledFieldName(key, dummyValue, colName, qbtypes.FilterOperatorUnknown)
stmts = append(stmts, colName)
}
}
} else {
@@ -111,10 +109,10 @@ func CollisionHandledFinalExpr(
} else if strings.Contains(field.Name, telemetrytypes.ArraySep) || strings.Contains(field.Name, telemetrytypes.ArrayAnyIndex) {
return "", nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "Group by/Aggregation isn't available for the Array Paths: %s", field.Name)
} else {
fieldExpression, _ = DataTypeCollisionHandledFieldName(field, dummyValue, fieldExpression, qbtypes.FilterOperatorUnknown)
colName, _ = DataTypeCollisionHandledFieldName(field, dummyValue, colName, qbtypes.FilterOperatorUnknown)
}
stmts = append(stmts, fieldExpression)
stmts = append(stmts, colName)
}
for idx := range stmts {

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -45,12 +44,12 @@ func keyIndexFilter(key *telemetrytypes.TelemetryFieldKey) any {
func (b *defaultConditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
op qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
_ uint64,
_ uint64,
) (string, error) {
if key.FieldContext != telemetrytypes.FieldContextResource {
@@ -61,23 +60,15 @@ func (b *defaultConditionBuilder) ConditionFor(
// as we store resource values as string
formattedValue := querybuilder.FormatValueForContains(value)
columns, err := b.fm.ColumnFor(ctx, startNs, endNs, key)
column, err := b.fm.ColumnFor(ctx, key)
if err != nil {
return "", err
}
if len(columns) != 1 {
return "", errors.Newf(errors.TypeInternal, errors.CodeInternal, "expected exactly 1 column, got %d", len(columns))
}
// resource evolution on main table doesn't affect this
// as we have not changed the resource column in the resource fingerprint table.
column := columns[0]
keyIdxFilter := sb.Like(column.Name, keyIndexFilter(key))
valueForIndexFilter := valueForIndexFilter(op, key, value)
fieldName, err := b.fm.FieldFor(ctx, startNs, endNs, key)
fieldName, err := b.fm.FieldFor(ctx, key)
if err != nil {
return "", err
}

View File

@@ -206,7 +206,7 @@ func TestConditionBuilder(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(context.Background(), 0, 0, tc.key, tc.op, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(context.Background(), tc.key, tc.op, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedErr != nil {

View File

@@ -27,50 +27,46 @@ func NewFieldMapper() *defaultFieldMapper {
func (m *defaultFieldMapper) getColumn(
_ context.Context,
_, _ uint64,
key *telemetrytypes.TelemetryFieldKey,
) ([]*schema.Column, error) {
) (*schema.Column, error) {
if key.FieldContext == telemetrytypes.FieldContextResource {
return []*schema.Column{resourceColumns["labels"]}, nil
return resourceColumns["labels"], nil
}
if col, ok := resourceColumns[key.Name]; ok {
return []*schema.Column{col}, nil
return col, nil
}
return nil, qbtypes.ErrColumnNotFound
}
func (m *defaultFieldMapper) ColumnFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
) ([]*schema.Column, error) {
return m.getColumn(ctx, tsStart, tsEnd, key)
) (*schema.Column, error) {
return m.getColumn(ctx, key)
}
func (m *defaultFieldMapper) FieldFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
) (string, error) {
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
column, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
if key.FieldContext == telemetrytypes.FieldContextResource {
return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", columns[0].Name, key.Name), nil
return fmt.Sprintf("simpleJSONExtractString(%s, '%s')", column.Name, key.Name), nil
}
return columns[0].Name, nil
return column.Name, nil
}
func (m *defaultFieldMapper) ColumnExpressionFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
_ map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
fieldExpression, err := m.FieldFor(ctx, tsStart, tsEnd, key)
colName, err := m.FieldFor(ctx, key)
if err != nil {
return "", err
}
return fmt.Sprintf("%s AS `%s`", fieldExpression, key.Name), nil
return fmt.Sprintf("%s AS `%s`", colName, key.Name), nil
}

View File

@@ -148,7 +148,7 @@ func (b *resourceFilterStatementBuilder[T]) Build(
// addConditions adds both filter and time conditions to the query
func (b *resourceFilterStatementBuilder[T]) addConditions(
ctx context.Context,
_ context.Context,
sb *sqlbuilder.SelectBuilder,
start, end uint64,
query qbtypes.QueryBuilderQuery[T],
@@ -160,7 +160,6 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
// warnings would be encountered as part of the main condition already
filterWhereClause, err := querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fieldMapper,
ConditionBuilder: b.conditionBuilder,
@@ -172,9 +171,7 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
// there is no need for "key" not found error for resource filtering
IgnoreNotFoundKeys: true,
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return err

View File

@@ -23,7 +23,6 @@ const stringMatchingOperatorDocURL = "https://signoz.io/docs/userguide/operators
// filterExpressionVisitor implements the FilterQueryVisitor interface
// to convert the parsed filter expressions into ClickHouse WHERE clause
type filterExpressionVisitor struct {
context context.Context
logger *slog.Logger
fieldMapper qbtypes.FieldMapper
conditionBuilder qbtypes.ConditionBuilder
@@ -47,7 +46,6 @@ type filterExpressionVisitor struct {
}
type FilterExprVisitorOpts struct {
Context context.Context
Logger *slog.Logger
FieldMapper qbtypes.FieldMapper
ConditionBuilder qbtypes.ConditionBuilder
@@ -67,7 +65,6 @@ type FilterExprVisitorOpts struct {
// newFilterExpressionVisitor creates a new filterExpressionVisitor
func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVisitor {
return &filterExpressionVisitor{
context: opts.Context,
logger: opts.Logger,
fieldMapper: opts.FieldMapper,
conditionBuilder: opts.ConditionBuilder,
@@ -93,7 +90,7 @@ type PreparedWhereClause struct {
}
// PrepareWhereClause generates a ClickHouse compatible WHERE clause from the filter query
func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWhereClause, error) {
func PrepareWhereClause(query string, opts FilterExprVisitorOpts, startNs uint64, endNs uint64) (*PreparedWhereClause, error) {
// Setup the ANTLR parsing pipeline
input := antlr.NewInputStream(query)
@@ -127,6 +124,8 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWher
}
tokens.Reset()
opts.StartNs = startNs
opts.EndNs = endNs
visitor := newFilterExpressionVisitor(opts)
// Handle syntax errors
@@ -318,7 +317,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
// create a full text search condition on the body field
keyText := keyCtx.GetText()
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(keyText), v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(keyText), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
@@ -338,7 +337,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
v.errors = append(v.errors, fmt.Sprintf("unsupported value type: %s", valCtx.GetText()))
return ""
}
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
@@ -382,7 +381,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, nil, v.builder)
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, nil, v.builder, v.startNs, v.endNs)
if err != nil {
return ""
}
@@ -454,7 +453,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
}
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, values, v.builder)
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, values, v.builder, v.startNs, v.endNs)
if err != nil {
return ""
}
@@ -502,7 +501,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, []any{value1, value2}, v.builder)
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, []any{value1, value2}, v.builder, v.startNs, v.endNs)
if err != nil {
return ""
}
@@ -587,7 +586,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
var conds []string
for _, key := range keys {
condition, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, key, op, value, v.builder)
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, value, v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build condition: %s", err.Error()))
return ""
@@ -666,7 +665,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
v.errors = append(v.errors, "full text search is not supported")
return ""
}
cond, err := v.conditionBuilder.ConditionFor(v.context, v.startNs, v.endNs, v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder, v.startNs, v.endNs)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
return ""
@@ -751,13 +750,13 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
if key.FieldContext == telemetrytypes.FieldContextBody {
var err error
if BodyJSONQueryEnabled {
fieldName, err = v.fieldMapper.FieldFor(v.context, v.startNs, v.endNs, key)
fieldName, err = v.fieldMapper.FieldFor(context.Background(), key)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to get field name for key %s: %s", key.Name, err.Error()))
return ""
}
} else {
fieldName, _ = v.jsonKeyToKey(v.context, key, qbtypes.FilterOperatorUnknown, value)
fieldName, _ = v.jsonKeyToKey(context.Background(), key, qbtypes.FilterOperatorUnknown, value)
}
} else {
// TODO(add docs for json body search)

View File

@@ -1,7 +1,6 @@
package querybuilder
import (
"context"
"log/slog"
"strings"
"testing"
@@ -55,12 +54,11 @@ func TestPrepareWhereClause_EmptyVariableList(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := FilterExprVisitorOpts{
Context: context.Background(),
FieldKeys: keys,
Variables: tt.variables,
}
_, err := PrepareWhereClause(tt.expr, opts)
_, err := PrepareWhereClause(tt.expr, opts, 0, 0)
if tt.expectError {
if err == nil {
@@ -469,7 +467,7 @@ func TestVisitKey(t *testing.T) {
expectedWarnings: nil,
expectedMainWrnURL: "",
},
{
{
name: "only attribute.custom_field is selected",
keyText: "attribute.attribute.custom_field",
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{

View File

@@ -170,6 +170,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddRootUserFactory(sqlstore, sqlschema),
sqlmigration.NewAddUserEmailOrgIDIndexFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
sqlmigration.NewAddStatusUserFactory(sqlstore, sqlschema),
sqlmigration.NewDeprecateUserInviteFactory(sqlstore, sqlschema),
)
}

View File

@@ -374,7 +374,6 @@ func New(
telemetrylogs.LogResourceKeysTblName,
telemetrymetadata.DBName,
telemetrymetadata.AttributesMetadataLocalTableName,
telemetrymetadata.ColumnEvolutionMetadataTableName,
)
global, err := factory.NewProviderFromNamedMap(

View File

@@ -0,0 +1,109 @@
package sqlmigration
import (
"context"
"time"
"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 addStatusUser struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddStatusUserFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_status_user"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addStatusUser{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *addStatusUser) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *addStatusUser) Up(ctx context.Context, db *bun.DB) error {
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()
}()
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("users"))
if err != nil {
return err
}
statusColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("status"),
DataType: sqlschema.DataTypeText,
Nullable: false,
}
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, statusColumn, "active")
// add deleted_at (zero time = not deleted, non-zero = deletion timestamp) to enable the
// composite unique index that replaces the partial index approach
deletedAtColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("deleted_at"),
DataType: sqlschema.DataTypeTimestamp,
Nullable: false,
}
sqls = append(sqls, migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, deletedAtColumn, time.Time{})...)
// we need to drop the unique index on (email, org_id)
sqls = append(sqls, migration.sqlschema.Operator().DropIndex(&sqlschema.UniqueIndex{TableName: "users", ColumnNames: []sqlschema.ColumnName{"email", "org_id"}})...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// add a composite unique index on (org_id, email, deleted_at).
// active and pending users have deleted_at=time.Time{} (zero), forming a unique (org_id, email, zero) tuple.
// soft-deleted users have deleted_at set to the deletion timestamp, making each deleted row unique
// and allowing the same email to be re-invited after deletion without a constraint violation.
newIndexSqls := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{TableName: "users", ColumnNames: []sqlschema.ColumnName{"org_id", "email", "deleted_at"}})
for _, sql := range newIndexSqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, true); err != nil {
return err
}
return nil
}
func (migration *addStatusUser) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,154 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"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 deprecateUserInvite struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
type userInviteRow struct {
bun.BaseModel `bun:"table:user_invite"`
ID string `bun:"id"`
Name string `bun:"name"`
Email string `bun:"email"`
Role string `bun:"role"`
OrgID string `bun:"org_id"`
Token string `bun:"token"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
}
type pendingInviteUser struct {
bun.BaseModel `bun:"table:users"`
ID string `bun:"id"`
DisplayName string `bun:"display_name"`
Email string `bun:"email"`
Role string `bun:"role"`
OrgID string `bun:"org_id"`
IsRoot bool `bun:"is_root"`
Status string `bun:"status"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
DeletedAt time.Time `bun:"deleted_at"`
}
func NewDeprecateUserInviteFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("deprecate_user_invite"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &deprecateUserInvite{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
},
)
}
func (migration *deprecateUserInvite) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *deprecateUserInvite) Up(ctx context.Context, db *bun.DB) error {
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("user_invite"))
if err != nil {
if err == sql.ErrNoRows || errors.Ast(err, errors.TypeNotFound) {
return nil
}
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// existing invites
var invites []*userInviteRow
err = tx.NewSelect().Model(&invites).Scan(ctx)
if err != nil && err != sql.ErrNoRows {
return err
}
// move all invitations to the users table as a pending_invite user
// skipping any invite whose email+org already has a user entry with non-deleted status
users := make([]*pendingInviteUser, 0, len(invites))
for _, invite := range invites {
existingCount, err := tx.NewSelect().
TableExpr("users").
Where("email = ?", invite.Email).
Where("org_id = ?", invite.OrgID).
Where("status != ?", "deleted").
Count(ctx)
if err != nil {
return err
}
if existingCount > 0 {
continue
}
user := &pendingInviteUser{
ID: invite.ID,
DisplayName: invite.Name,
Email: invite.Email,
Role: invite.Role,
OrgID: invite.OrgID,
IsRoot: false,
Status: "pending_invite",
CreatedAt: invite.CreatedAt,
UpdatedAt: time.Now(),
DeletedAt: time.Time{},
}
users = append(users, user)
}
if len(users) > 0 {
_, err = tx.NewInsert().Model(&users).Exec(ctx)
if err != nil {
return err
}
}
// finally drop the user_invite table
dropTableSqls := migration.sqlschema.Operator().DropTable(table)
for _, sql := range dropTableSqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (migration *deprecateUserInvite) Down(ctx context.Context, db *bun.DB) error {
return nil
}

View File

@@ -25,60 +25,56 @@ func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
func (c *conditionBuilder) conditionFor(
ctx context.Context,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
columns, err := c.fm.ColumnFor(ctx, startNs, endNs, key)
column, err := c.fm.ColumnFor(ctx, key)
if err != nil {
return "", err
}
// TODO(Piyush): Update this to support multiple JSON columns based on evolutions
for _, column := range columns {
if column.IsJSONColumn() && querybuilder.BodyJSONQueryEnabled {
valueType, value := InferDataType(value, operator, key)
cond, err := NewJSONConditionBuilder(key, valueType).buildJSONCondition(operator, value, sb)
if err != nil {
return "", err
}
return cond, nil
if column.IsJSONColumn() && querybuilder.BodyJSONQueryEnabled {
valueType, value := InferDataType(value, operator, key)
cond, err := NewJSONConditionBuilder(key, valueType).buildJSONCondition(operator, value, sb)
if err != nil {
return "", err
}
return cond, nil
}
if operator.IsStringSearchOperator() {
value = querybuilder.FormatValueForContains(value)
}
fieldExpression, err := c.fm.FieldFor(ctx, startNs, endNs, key)
tblFieldName, err := c.fm.FieldFor(ctx, key)
if err != nil {
return "", err
}
// Check if this is a body JSON search - either by FieldContext
if key.FieldContext == telemetrytypes.FieldContextBody {
fieldExpression, value = GetBodyJSONKey(ctx, key, operator, value)
tblFieldName, value = GetBodyJSONKey(ctx, key, operator, value)
}
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
tblFieldName, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, tblFieldName, operator)
// make use of case insensitive index for body
if fieldExpression == "body" {
if tblFieldName == "body" {
switch operator {
case qbtypes.FilterOperatorLike:
return sb.ILike(fieldExpression, value), nil
return sb.ILike(tblFieldName, value), nil
case qbtypes.FilterOperatorNotLike:
return sb.NotILike(fieldExpression, value), nil
return sb.NotILike(tblFieldName, value), nil
case qbtypes.FilterOperatorRegexp:
// Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs
// Only needed because we are using sprintf instead of sb.Match (not implemented in sqlbuilder)
return fmt.Sprintf(`match(LOWER(%s), LOWER(%s))`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
return fmt.Sprintf(`match(LOWER(%s), LOWER(%s))`, sqlbuilder.Escape(tblFieldName), sb.Var(value)), nil
case qbtypes.FilterOperatorNotRegexp:
// Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs
// Only needed because we are using sprintf instead of sb.Match (not implemented in sqlbuilder)
return fmt.Sprintf(`NOT match(LOWER(%s), LOWER(%s))`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
return fmt.Sprintf(`NOT match(LOWER(%s), LOWER(%s))`, sqlbuilder.Escape(tblFieldName), sb.Var(value)), nil
}
}
@@ -86,41 +82,41 @@ func (c *conditionBuilder) conditionFor(
switch operator {
// regular operators
case qbtypes.FilterOperatorEqual:
return sb.E(fieldExpression, value), nil
return sb.E(tblFieldName, value), nil
case qbtypes.FilterOperatorNotEqual:
return sb.NE(fieldExpression, value), nil
return sb.NE(tblFieldName, value), nil
case qbtypes.FilterOperatorGreaterThan:
return sb.G(fieldExpression, value), nil
return sb.G(tblFieldName, value), nil
case qbtypes.FilterOperatorGreaterThanOrEq:
return sb.GE(fieldExpression, value), nil
return sb.GE(tblFieldName, value), nil
case qbtypes.FilterOperatorLessThan:
return sb.LT(fieldExpression, value), nil
return sb.LT(tblFieldName, value), nil
case qbtypes.FilterOperatorLessThanOrEq:
return sb.LE(fieldExpression, value), nil
return sb.LE(tblFieldName, value), nil
// like and not like
case qbtypes.FilterOperatorLike:
return sb.Like(fieldExpression, value), nil
return sb.Like(tblFieldName, value), nil
case qbtypes.FilterOperatorNotLike:
return sb.NotLike(fieldExpression, value), nil
return sb.NotLike(tblFieldName, value), nil
case qbtypes.FilterOperatorILike:
return sb.ILike(fieldExpression, value), nil
return sb.ILike(tblFieldName, value), nil
case qbtypes.FilterOperatorNotILike:
return sb.NotILike(fieldExpression, value), nil
return sb.NotILike(tblFieldName, value), nil
case qbtypes.FilterOperatorContains:
return sb.ILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
return sb.ILike(tblFieldName, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorNotContains:
return sb.NotILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
return sb.NotILike(tblFieldName, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorRegexp:
// Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs
// Only needed because we are using sprintf instead of sb.Match (not implemented in sqlbuilder)
return fmt.Sprintf(`match(%s, %s)`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
return fmt.Sprintf(`match(%s, %s)`, sqlbuilder.Escape(tblFieldName), sb.Var(value)), nil
case qbtypes.FilterOperatorNotRegexp:
// Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs
// Only needed because we are using sprintf instead of sb.Match (not implemented in sqlbuilder)
return fmt.Sprintf(`NOT match(%s, %s)`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
return fmt.Sprintf(`NOT match(%s, %s)`, sqlbuilder.Escape(tblFieldName), sb.Var(value)), nil
// between and not between
case qbtypes.FilterOperatorBetween:
values, ok := value.([]any)
@@ -130,7 +126,7 @@ func (c *conditionBuilder) conditionFor(
if len(values) != 2 {
return "", qbtypes.ErrBetweenValues
}
return sb.Between(fieldExpression, values[0], values[1]), nil
return sb.Between(tblFieldName, values[0], values[1]), nil
case qbtypes.FilterOperatorNotBetween:
values, ok := value.([]any)
if !ok {
@@ -139,7 +135,7 @@ func (c *conditionBuilder) conditionFor(
if len(values) != 2 {
return "", qbtypes.ErrBetweenValues
}
return sb.NotBetween(fieldExpression, values[0], values[1]), nil
return sb.NotBetween(tblFieldName, values[0], values[1]), nil
// in and not in
case qbtypes.FilterOperatorIn:
@@ -150,7 +146,7 @@ func (c *conditionBuilder) conditionFor(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.E(fieldExpression, value))
conditions = append(conditions, sb.E(tblFieldName, value))
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -161,7 +157,7 @@ func (c *conditionBuilder) conditionFor(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.NE(fieldExpression, value))
conditions = append(conditions, sb.NE(tblFieldName, value))
}
return sb.And(conditions...), nil
@@ -178,62 +174,37 @@ func (c *conditionBuilder) conditionFor(
}
var value any
column := columns[0]
if len(key.Evolutions) > 0 {
// we will use the corresponding column and its evolution entry for the query
newColumns, _, err := selectEvolutionsForColumns(columns, key.Evolutions, startNs, endNs)
if err != nil {
return "", err
}
if len(newColumns) == 0 {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "no valid evolution found for field %s in the given time range", key.Name)
}
// This mean tblFieldName is with multiIf, we just need to do a null check.
if len(newColumns) > 1 {
if operator == qbtypes.FilterOperatorExists {
return sb.IsNotNull(fieldExpression), nil
} else {
return sb.IsNull(fieldExpression), nil
}
}
// otherwise we have to find the correct exist operator based on the column type
column = newColumns[0]
}
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
if operator == qbtypes.FilterOperatorExists {
return sb.IsNotNull(fieldExpression), nil
return sb.IsNotNull(tblFieldName), nil
} else {
return sb.IsNull(fieldExpression), nil
return sb.IsNull(tblFieldName), nil
}
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
value = ""
if operator == qbtypes.FilterOperatorExists {
return sb.NE(fieldExpression, value), nil
return sb.NE(tblFieldName, value), nil
}
return sb.E(fieldExpression, value), nil
return sb.E(tblFieldName, value), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
}
case schema.ColumnTypeEnumString:
value = ""
if operator == qbtypes.FilterOperatorExists {
return sb.NE(fieldExpression, value), nil
return sb.NE(tblFieldName, value), nil
} else {
return sb.E(fieldExpression, value), nil
return sb.E(tblFieldName, value), nil
}
case schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
value = 0
if operator == qbtypes.FilterOperatorExists {
return sb.NE(fieldExpression, value), nil
return sb.NE(tblFieldName, value), nil
} else {
return sb.E(fieldExpression, value), nil
return sb.E(tblFieldName, value), nil
}
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
@@ -257,7 +228,6 @@ func (c *conditionBuilder) conditionFor(
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for column type %s", column.Type)
}
}
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)
@@ -265,15 +235,14 @@ func (c *conditionBuilder) conditionFor(
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
_ uint64,
_ uint64,
) (string, error) {
condition, err := c.conditionFor(ctx, startNs, endNs, key, operator, value, sb)
condition, err := c.conditionFor(ctx, key, operator, value, sb)
if err != nil {
return "", err
}
@@ -281,12 +250,12 @@ func (c *conditionBuilder) ConditionFor(
if !(key.FieldContext == telemetrytypes.FieldContextBody && querybuilder.BodyJSONQueryEnabled) && operator.AddDefaultExistsFilter() {
// skip adding exists filter for intrinsic fields
// with an exception for body json search
fieldExpression, _ := c.fm.FieldFor(ctx, startNs, endNs, key)
if slices.Contains(maps.Keys(IntrinsicFields), fieldExpression) && key.FieldContext != telemetrytypes.FieldContextBody {
field, _ := c.fm.FieldFor(ctx, key)
if slices.Contains(maps.Keys(IntrinsicFields), field) && key.FieldContext != telemetrytypes.FieldContextBody {
return condition, nil
}
existsCondition, err := c.conditionFor(ctx, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
existsCondition, err := c.conditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return "", err
}

View File

@@ -3,7 +3,6 @@ package telemetrylogs
import (
"context"
"testing"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -12,148 +11,14 @@ import (
"github.com/stretchr/testify/require"
)
func TestExistsConditionForWithEvolutions(t *testing.T) {
testCases := []struct {
name string
startTs uint64
endTs uint64
key telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
expectedSQL string
expectedArgs []any
expectedError error
}{
{
name: "New column",
startTs: uint64(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
endTs: uint64(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
FieldContext: telemetrytypes.FieldContextResource,
ColumnType: "Map(LowCardinality(String), String)",
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "WHERE resource.`service.name`::String IS NOT NULL",
expectedError: nil,
},
{
name: "Old column",
startTs: uint64(time.Date(2023, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
endTs: uint64(time.Date(2023, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
FieldContext: telemetrytypes.FieldContextResource,
ColumnType: "Map(LowCardinality(String), String)",
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "WHERE mapContains(resources_string, 'service.name') = ?",
expectedArgs: []any{true},
expectedError: nil,
},
{
name: "Both Old column and new - empty filter",
startTs: uint64(time.Date(2023, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
endTs: uint64(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC).UnixNano()),
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
FieldContext: telemetrytypes.FieldContextResource,
ColumnType: "Map(LowCardinality(String), String)",
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
},
},
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL",
expectedError: nil,
},
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
ctx := context.Background()
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, tc.startTs, tc.endTs, &tc.key, tc.operator, tc.value, sb)
sb.Where(cond)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
} else {
require.NoError(t, err)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, tc.expectedSQL)
assert.Equal(t, tc.expectedArgs, args)
}
})
}
}
func TestConditionFor(t *testing.T) {
ctx := context.Background()
mockEvolution := mockEvolutionData(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC))
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
evolutions []*telemetrytypes.EvolutionEntry
expectedSQL string
expectedArgs []any
expectedError error
@@ -375,11 +240,9 @@ func TestConditionFor(t *testing.T) {
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
evolutions: mockEvolution,
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "mapContains(resources_string, 'service.name') = ?",
expectedArgs: []any{true},
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL",
expectedError: nil,
},
{
@@ -389,11 +252,9 @@ func TestConditionFor(t *testing.T) {
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
evolutions: mockEvolution,
operator: qbtypes.FilterOperatorNotExists,
value: nil,
expectedSQL: "mapContains(resources_string, 'service.name') <> ?",
expectedArgs: []any{true},
expectedSQL: "WHERE multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NULL",
expectedError: nil,
},
{
@@ -454,11 +315,10 @@ func TestConditionFor(t *testing.T) {
FieldDataType: telemetrytypes.FieldDataTypeString,
Materialized: true,
},
evolutions: mockEvolution,
operator: qbtypes.FilterOperatorRegexp,
value: "frontend-.*",
expectedSQL: "WHERE (match(`resource_string_service$$name`, ?) AND `resource_string_service$$name_exists` = ?)",
expectedArgs: []any{"frontend-.*", true},
expectedSQL: "(match(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL), ?) AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL) IS NOT NULL)",
expectedArgs: []any{"frontend-.*"},
expectedError: nil,
},
{
@@ -469,10 +329,9 @@ func TestConditionFor(t *testing.T) {
FieldDataType: telemetrytypes.FieldDataTypeString,
Materialized: true,
},
evolutions: mockEvolution,
operator: qbtypes.FilterOperatorNotRegexp,
value: "test-.*",
expectedSQL: "WHERE NOT match(`resource_string_service$$name`, ?)",
expectedSQL: "WHERE NOT match(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL), ?)",
expectedArgs: []any{"test-.*"},
expectedError: nil,
},
@@ -512,13 +371,14 @@ func TestConditionFor(t *testing.T) {
expectedError: qbtypes.ErrColumnNotFound,
},
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
tc.key.Evolutions = tc.evolutions
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedError != nil {
@@ -573,7 +433,7 @@ func TestConditionForMultipleKeys(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var err error
for _, key := range tc.keys {
cond, err := conditionBuilder.conditionFor(ctx, 0, 0, &key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if err != nil {
t.Fatalf("Error getting condition for key %s: %v", key.Name, err)
@@ -830,7 +690,7 @@ func TestConditionForJSONBodySearch(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.conditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedError != nil {

View File

@@ -3,11 +3,7 @@ package telemetrylogs
import (
"context"
"fmt"
"slices"
"sort"
"strconv"
"strings"
"time"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/utils"
@@ -65,42 +61,40 @@ var (
}
)
type fieldMapper struct{}
type fieldMapper struct {}
func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
columns := []*schema.Column{logsV2Columns["resources_string"], logsV2Columns["resource"]}
return columns, nil
return logsV2Columns["resource"], nil
case telemetrytypes.FieldContextScope:
switch key.Name {
case "name", "scope.name", "scope_name":
return []*schema.Column{logsV2Columns["scope_name"]}, nil
return logsV2Columns["scope_name"], nil
case "version", "scope.version", "scope_version":
return []*schema.Column{logsV2Columns["scope_version"]}, nil
return logsV2Columns["scope_version"], nil
}
return []*schema.Column{logsV2Columns["scope_string"]}, nil
return logsV2Columns["scope_string"], nil
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
return []*schema.Column{logsV2Columns["attributes_string"]}, nil
return logsV2Columns["attributes_string"], nil
case telemetrytypes.FieldDataTypeInt64, telemetrytypes.FieldDataTypeFloat64, telemetrytypes.FieldDataTypeNumber:
return []*schema.Column{logsV2Columns["attributes_number"]}, nil
return logsV2Columns["attributes_number"], nil
case telemetrytypes.FieldDataTypeBool:
return []*schema.Column{logsV2Columns["attributes_bool"]}, nil
return logsV2Columns["attributes_bool"], nil
}
case telemetrytypes.FieldContextBody:
// Body context is for JSON body fields
// Use body_json if feature flag is enabled
if querybuilder.BodyJSONQueryEnabled {
return []*schema.Column{logsV2Columns[LogsV2BodyJSONColumn]}, nil
return logsV2Columns[LogsV2BodyJSONColumn], nil
}
// Fall back to legacy body column
return []*schema.Column{logsV2Columns["body"]}, nil
return logsV2Columns["body"], nil
case telemetrytypes.FieldContextLog, telemetrytypes.FieldContextUnspecified:
col, ok := logsV2Columns[key.Name]
if !ok {
@@ -108,242 +102,96 @@ func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.Telemetry
if strings.HasPrefix(key.Name, telemetrytypes.BodyJSONStringSearchPrefix) {
// Use body_json if feature flag is enabled and we have a body condition builder
if querybuilder.BodyJSONQueryEnabled {
// TODO(Piyush): Update this to support multiple JSON columns based on evolutions
// i.e return both the body json and body json promoted and let the evolutions decide which one to use
// based on the query range time.
return []*schema.Column{logsV2Columns[LogsV2BodyJSONColumn]}, nil
return logsV2Columns[LogsV2BodyJSONColumn], nil
}
// Fall back to legacy body column
return []*schema.Column{logsV2Columns["body"]}, nil
return logsV2Columns["body"], nil
}
return nil, qbtypes.ErrColumnNotFound
}
return []*schema.Column{col}, nil
return col, nil
}
return nil, qbtypes.ErrColumnNotFound
}
// selectEvolutionsForColumns selects the appropriate evolution entries for each column based on the time range.
// Logic:
// - Finds the latest base evolution (<= tsStartTime) across ALL columns
// - Rejects all evolutions before this latest base evolution
// - For duplicate evolutions it considers the oldest one (first in ReleaseTime)
// - For each column, includes its evolution if it's >= latest base evolution and <= tsEndTime
// - Results are sorted by ReleaseTime descending (newest first)
func selectEvolutionsForColumns(columns []*schema.Column, evolutions []*telemetrytypes.EvolutionEntry, tsStart, tsEnd uint64) ([]*schema.Column, []*telemetrytypes.EvolutionEntry, error) {
sortedEvolutions := make([]*telemetrytypes.EvolutionEntry, len(evolutions))
copy(sortedEvolutions, evolutions)
// sort the evolutions by ReleaseTime ascending
sort.Slice(sortedEvolutions, func(i, j int) bool {
return sortedEvolutions[i].ReleaseTime.Before(sortedEvolutions[j].ReleaseTime)
})
tsStartTime := time.Unix(0, int64(tsStart))
tsEndTime := time.Unix(0, int64(tsEnd))
// Build evolution map: column name -> evolution
evolutionMap := make(map[string]*telemetrytypes.EvolutionEntry)
for _, evolution := range sortedEvolutions {
if _, exists := evolutionMap[evolution.ColumnName+":"+evolution.FieldName+":"+strconv.Itoa(int(evolution.Version))]; exists {
// since if there is duplicate we would just use the oldest one.
continue
}
evolutionMap[evolution.ColumnName+":"+evolution.FieldName+":"+strconv.Itoa(int(evolution.Version))] = evolution
}
// Find the latest base evolution (<= tsStartTime) across ALL columns
// Evolutions are sorted, so we can break early
var latestBaseEvolutionAcrossAll *telemetrytypes.EvolutionEntry
for _, evolution := range sortedEvolutions {
if evolution.ReleaseTime.After(tsStartTime) {
break
}
latestBaseEvolutionAcrossAll = evolution
}
// We shouldn't reach this, it basically means there is something wrong with the evolutions data
if latestBaseEvolutionAcrossAll == nil {
return nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "no base evolution found for columns %v", columns)
}
columnLookUpMap := make(map[string]*schema.Column)
for _, column := range columns {
columnLookUpMap[column.Name] = column
}
// Collect column-evolution pairs
type colEvoPair struct {
column *schema.Column
evolution *telemetrytypes.EvolutionEntry
}
pairs := []colEvoPair{}
for _, evolution := range evolutionMap {
// Reject evolutions before the latest base evolution
if evolution.ReleaseTime.Before(latestBaseEvolutionAcrossAll.ReleaseTime) {
continue
}
// skip evolutions after tsEndTime
if evolution.ReleaseTime.After(tsEndTime) || evolution.ReleaseTime.Equal(tsEndTime) {
continue
}
if _, exists := columnLookUpMap[evolution.ColumnName]; !exists {
return nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "evolution column %s not found in columns %v", evolution.ColumnName, columns)
}
pairs = append(pairs, colEvoPair{columnLookUpMap[evolution.ColumnName], evolution})
}
// If no pairs found, fall back to latestBaseEvolutionAcrossAll for matching columns
if len(pairs) == 0 {
for _, column := range columns {
// Use latestBaseEvolutionAcrossAll if this column name matches its column name
if column.Name == latestBaseEvolutionAcrossAll.ColumnName {
pairs = append(pairs, colEvoPair{column, latestBaseEvolutionAcrossAll})
}
}
}
// Sort by ReleaseTime descending (newest first)
slices.SortFunc(pairs, func(a, b colEvoPair) int {
// Sort by ReleaseTime descending (newest first)
if a.evolution.ReleaseTime.After(b.evolution.ReleaseTime) {
return -1
}
if a.evolution.ReleaseTime.Before(b.evolution.ReleaseTime) {
return 1
}
return 0
})
// Extract results
newColumns := make([]*schema.Column, len(pairs))
evolutionsEntries := make([]*telemetrytypes.EvolutionEntry, len(pairs))
for i, pair := range pairs {
newColumns[i] = pair.column
evolutionsEntries[i] = pair.evolution
}
return newColumns, evolutionsEntries, nil
}
func (m *fieldMapper) FieldFor(ctx context.Context, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumn(ctx, key)
func (m *fieldMapper) FieldFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) (string, error) {
column, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
var newColumns []*schema.Column
var evolutionsEntries []*telemetrytypes.EvolutionEntry
if len(key.Evolutions) > 0 {
// we will use the corresponding column and its evolution entry for the query
newColumns, evolutionsEntries, err = selectEvolutionsForColumns(columns, key.Evolutions, tsStart, tsEnd)
if err != nil {
return "", err
}
} else {
newColumns = columns
}
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// json is only supported for resource context as of now
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
oldColumn := logsV2Columns["resources_string"]
oldKeyName := fmt.Sprintf("%s['%s']", oldColumn.Name, key.Name)
exprs := []string{}
existExpr := []string{}
for i, column := range newColumns {
// Use evolution column name if available, otherwise use the column name
columnName := column.Name
if evolutionsEntries != nil && evolutionsEntries[i] != nil {
columnName = evolutionsEntries[i].ColumnName
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
// once clickHouse dependency is updated, we need to check if we can remove it.
if key.Materialized {
oldKeyName = telemetrytypes.FieldKeyToMaterializedColumnName(key)
oldKeyNameExists := telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, %s==true, %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldKeyNameExists, oldKeyName), nil
}
return fmt.Sprintf("multiIf(%s.`%s` IS NOT NULL, %s.`%s`::String, mapContains(%s, '%s'), %s, NULL)", column.Name, key.Name, column.Name, key.Name, oldColumn.Name, key.Name, oldKeyName), nil
case telemetrytypes.FieldContextBody:
if key.JSONDataType == nil {
return "", qbtypes.ErrColumnNotFound
}
if key.KeyNameContainsArray() && !key.JSONDataType.IsArray {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "FieldFor not supported for nested fields; only supported for flat paths (e.g. body.status.detail) and paths of Array type: %s(%s)", key.Name, key.FieldDataType)
}
return m.buildFieldForJSON(key)
default:
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource/body context fields are supported for json columns, got %s", key.FieldContext.String)
}
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
return column.Name, nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
return column.Name, nil
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExpr = append(existExpr, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
case telemetrytypes.FieldContextBody:
if key.JSONDataType == nil {
return "", qbtypes.ErrColumnNotFound
}
if key.KeyNameContainsArray() && !key.JSONDataType.IsArray {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "FieldFor not supported for nested fields; only supported for flat paths (e.g. body.status.detail) and paths of Array type: %s(%s)", key.Name, key.FieldDataType)
}
expr, err := m.buildFieldForJSON(key)
if err != nil {
return "", err
}
exprs = append(exprs, expr)
default:
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource/body context fields are supported for json columns, got %s", key.FieldContext.String)
}
case schema.ColumnTypeEnumLowCardinality:
switch elementType := column.Type.(schema.LowCardinalityColumnType).ElementType; elementType.GetType() {
case schema.ColumnTypeEnumString:
exprs = append(exprs, column.Name)
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for low cardinality column type %s", elementType)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64, schema.ColumnTypeEnumUInt32, schema.ColumnTypeEnumUInt8:
exprs = append(exprs, column.Name)
case schema.ColumnTypeEnumMap:
keyType := column.Type.(schema.MapColumnType).KeyType
if _, ok := keyType.(schema.LowCardinalityColumnType); !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "key type %s is not supported for map column type %s", keyType, column.Type)
}
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(key))
existExpr = append(existExpr, fmt.Sprintf("%s==true", telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)))
} else {
exprs = append(exprs, fmt.Sprintf("%s['%s']", columnName, key.Name))
existExpr = append(existExpr, fmt.Sprintf("mapContains(%s, '%s')", columnName, key.Name))
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
// a key could have been materialized, if so return the materialized column name
if key.Materialized {
return telemetrytypes.FieldKeyToMaterializedColumnName(key), nil
}
return fmt.Sprintf("%s['%s']", column.Name, key.Name), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "exists operator is not supported for map column type %s", valueType)
}
}
if len(exprs) == 1 {
return exprs[0], nil
} else if len(exprs) > 1 {
// Ensure existExpr has the same length as exprs
if len(existExpr) != len(exprs) {
return "", errors.New(errors.TypeInternal, errors.CodeInternal, "length of exist exprs doesn't match to that of exprs")
}
finalExprs := []string{}
for i, expr := range exprs {
finalExprs = append(finalExprs, fmt.Sprintf("%s, %s", existExpr[i], expr))
}
return "multiIf(" + strings.Join(finalExprs, ", ") + ", NULL)", nil
}
// should not reach here
return columns[0].Name, nil
return column.Name, nil
}
func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
func (m *fieldMapper) ColumnFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
return m.getColumn(ctx, key)
}
func (m *fieldMapper) ColumnExpressionFor(
ctx context.Context,
tsStart, tsEnd uint64,
field *telemetrytypes.TelemetryFieldKey,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
fieldExpression, err := m.FieldFor(ctx, tsStart, tsEnd, field)
colName, err := m.FieldFor(ctx, field)
if errors.Is(err, qbtypes.ErrColumnNotFound) {
// the key didn't have the right context to be added to the query
// we try to use the context we know of
@@ -353,7 +201,7 @@ func (m *fieldMapper) ColumnExpressionFor(
if _, ok := logsV2Columns[field.Name]; ok {
// if it is, attach the column name directly
field.FieldContext = telemetrytypes.FieldContextLog
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, field)
colName, _ = m.FieldFor(ctx, field)
} else {
// - the context is not provided
// - there are not keys for the field
@@ -371,19 +219,19 @@ func (m *fieldMapper) ColumnExpressionFor(
}
} else if len(keysForField) == 1 {
// we have a single key for the field, use it
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, keysForField[0])
colName, _ = m.FieldFor(ctx, keysForField[0])
} else {
// select any non-empty value from the keys
args := []string{}
for _, key := range keysForField {
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, key)
args = append(args, fmt.Sprintf("toString(%s) != '', toString(%s)", fieldExpression, fieldExpression))
colName, _ = m.FieldFor(ctx, key)
args = append(args, fmt.Sprintf("toString(%s) != '', toString(%s)", colName, colName))
}
fieldExpression = fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", "))
colName = fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", "))
}
}
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(fieldExpression), field.Name), nil
return fmt.Sprintf("%s AS `%s`", sqlbuilder.Escape(colName), field.Name), nil
}
// buildFieldForJSON builds the field expression for body JSON fields using arrayConcat pattern

View File

@@ -3,7 +3,6 @@ package telemetrylogs
import (
"context"
"testing"
"time"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -18,7 +17,7 @@ func TestGetColumn(t *testing.T) {
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
expectedCol []*schema.Column
expectedCol *schema.Column
expectedError error
}{
{
@@ -27,7 +26,7 @@ func TestGetColumn(t *testing.T) {
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
},
expectedCol: []*schema.Column{logsV2Columns["resources_string"], logsV2Columns["resource"]},
expectedCol: logsV2Columns["resource"],
expectedError: nil,
},
{
@@ -36,7 +35,7 @@ func TestGetColumn(t *testing.T) {
Name: "name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_name"]},
expectedCol: logsV2Columns["scope_name"],
expectedError: nil,
},
{
@@ -45,7 +44,7 @@ func TestGetColumn(t *testing.T) {
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_name"]},
expectedCol: logsV2Columns["scope_name"],
expectedError: nil,
},
{
@@ -54,7 +53,7 @@ func TestGetColumn(t *testing.T) {
Name: "scope_name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_name"]},
expectedCol: logsV2Columns["scope_name"],
expectedError: nil,
},
{
@@ -63,7 +62,7 @@ func TestGetColumn(t *testing.T) {
Name: "version",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_version"]},
expectedCol: logsV2Columns["scope_version"],
expectedError: nil,
},
{
@@ -72,7 +71,7 @@ func TestGetColumn(t *testing.T) {
Name: "custom.scope.field",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedCol: []*schema.Column{logsV2Columns["scope_string"]},
expectedCol: logsV2Columns["scope_string"],
expectedError: nil,
},
{
@@ -82,7 +81,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_string"]},
expectedCol: logsV2Columns["attributes_string"],
expectedError: nil,
},
{
@@ -92,7 +91,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_number"]},
expectedCol: logsV2Columns["attributes_number"],
expectedError: nil,
},
{
@@ -102,7 +101,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeInt64,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_number"]},
expectedCol: logsV2Columns["attributes_number"],
expectedError: nil,
},
{
@@ -112,7 +111,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_number"]},
expectedCol: logsV2Columns["attributes_number"],
expectedError: nil,
},
{
@@ -122,7 +121,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_bool"]},
expectedCol: logsV2Columns["attributes_bool"],
expectedError: nil,
},
{
@@ -131,7 +130,7 @@ func TestGetColumn(t *testing.T) {
Name: "timestamp",
FieldContext: telemetrytypes.FieldContextLog,
},
expectedCol: []*schema.Column{logsV2Columns["timestamp"]},
expectedCol: logsV2Columns["timestamp"],
expectedError: nil,
},
{
@@ -140,7 +139,7 @@ func TestGetColumn(t *testing.T) {
Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
},
expectedCol: []*schema.Column{logsV2Columns["body"]},
expectedCol: logsV2Columns["body"],
expectedError: nil,
},
{
@@ -160,7 +159,7 @@ func TestGetColumn(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
expectedCol: []*schema.Column{logsV2Columns["attributes_bool"]},
expectedCol: logsV2Columns["attributes_bool"],
expectedError: nil,
},
}
@@ -169,7 +168,7 @@ func TestGetColumn(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
col, err := fm.ColumnFor(ctx, 0, 0, &tc.key)
col, err := fm.ColumnFor(ctx, &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
@@ -184,14 +183,11 @@ func TestGetColumn(t *testing.T) {
func TestGetFieldKeyName(t *testing.T) {
ctx := context.Background()
resourceEvolution := mockEvolutionData(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC))
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
expectedResult string
expectedError error
addExistsFilter bool
name string
key telemetrytypes.TelemetryFieldKey
expectedResult string
expectedError error
}{
{
name: "Simple column type - timestamp",
@@ -199,9 +195,8 @@ func TestGetFieldKeyName(t *testing.T) {
Name: "timestamp",
FieldContext: telemetrytypes.FieldContextLog,
},
expectedResult: "timestamp",
expectedError: nil,
addExistsFilter: false,
expectedResult: "timestamp",
expectedError: nil,
},
{
name: "Map column type - string attribute",
@@ -210,9 +205,8 @@ func TestGetFieldKeyName(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
expectedResult: "attributes_string['user.id']",
expectedError: nil,
addExistsFilter: false,
expectedResult: "attributes_string['user.id']",
expectedError: nil,
},
{
name: "Map column type - number attribute",
@@ -221,9 +215,8 @@ func TestGetFieldKeyName(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
},
expectedResult: "attributes_number['request.size']",
expectedError: nil,
addExistsFilter: false,
expectedResult: "attributes_number['request.size']",
expectedError: nil,
},
{
name: "Map column type - bool attribute",
@@ -232,33 +225,28 @@ func TestGetFieldKeyName(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
expectedResult: "attributes_bool['request.success']",
expectedError: nil,
addExistsFilter: false,
expectedResult: "attributes_bool['request.success']",
expectedError: nil,
},
{
name: "Map column type - resource attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
Evolutions: resourceEvolution,
},
expectedResult: "resources_string['service.name']",
expectedError: nil,
addExistsFilter: false,
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
expectedError: nil,
},
{
name: "Map column type - resource attribute - Materialized - json",
name: "Map column type - resource attribute - Materialized",
key: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Materialized: true,
Evolutions: resourceEvolution,
},
expectedResult: "`resource_string_service$$name`",
expectedError: nil,
addExistsFilter: false,
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL)",
expectedError: nil,
},
{
name: "Non-existent column",
@@ -274,7 +262,7 @@ func TestGetFieldKeyName(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper()
result, err := fm.FieldFor(ctx, 0, 0, &tc.key)
result, err := fm.FieldFor(ctx, &tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
@@ -285,693 +273,3 @@ func TestGetFieldKeyName(t *testing.T) {
})
}
}
func TestFieldForWithEvolutions(t *testing.T) {
ctx := context.Background()
key := &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
}
testCases := []struct {
name string
evolutions []*telemetrytypes.EvolutionEntry
key *telemetrytypes.TelemetryFieldKey
tsStartTime time.Time
tsEndTime time.Time
expectedResult string
expectedError error
}{
{
name: "Single evolution before tsStartTime",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resources_string['service.name']",
expectedError: nil,
},
{
name: "Single evolution exactly at tsStartTime",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resources_string['service.name']",
expectedError: nil,
},
{
name: "Single evolution after tsStartTime",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
expectedError: nil,
},
// TODO(piyush): to be added once integration with JSON is done.
// {
// name: "Single evolution after tsStartTime - JSON body",
// evolutions: []*telemetrytypes.EvolutionEntry{
// {
// Signal: telemetrytypes.SignalLogs,
// ColumnName: LogsV2BodyJSONColumn,
// ColumnType: "JSON(max_dynamic_paths=0)",
// FieldContext: telemetrytypes.FieldContextBody,
// FieldName: "__all__",
// ReleaseTime: time.Unix(0, 0),
// },
// {
// Signal: telemetrytypes.SignalLogs,
// ColumnName: LogsV2BodyPromotedColumn,
// ColumnType: "JSON()",
// FieldContext: telemetrytypes.FieldContextBody,
// FieldName: "user.name",
// ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
// },
// },
// key: &telemetrytypes.TelemetryFieldKey{
// Name: "user.name",
// FieldContext: telemetrytypes.FieldContextBody,
// JSONDataType: &telemetrytypes.String,
// Materialized: true,
// },
// tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
// tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
// expectedResult: "coalesce(dynamicElement(body_json.`user.name`, 'String'), dynamicElement(body_promoted.`user.name`, 'String'))",
// expectedError: nil,
// },
{
name: "Multiple evolutions before tsStartTime - only latest should be included",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resource.`service.name`::String",
expectedError: nil,
},
{
name: "Multiple evolutions after tsStartTime - all should be included",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Unix(0, 0),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)",
expectedError: nil,
},
{
name: "Duplicate evolutions after tsStartTime - all should be included",
// Note: on production when this happens, we should go ahead and clean it up if required
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resource.`service.name`::String",
expectedError: nil,
},
{
name: "Evolution exactly at tsEndTime - should not be included",
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
},
},
key: key,
tsStartTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
tsEndTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC),
expectedResult: "resources_string['service.name']",
expectedError: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper()
tsStart := uint64(tc.tsStartTime.UnixNano())
tsEnd := uint64(tc.tsEndTime.UnixNano())
tc.key.Evolutions = tc.evolutions
result, err := fm.FieldFor(ctx, tsStart, tsEnd, tc.key)
if tc.expectedError != nil {
assert.Equal(t, tc.expectedError, err)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
}
})
}
}
func TestSelectEvolutionsForColumns(t *testing.T) {
testCases := []struct {
name string
columns []*schema.Column
evolutions []*telemetrytypes.EvolutionEntry
tsStart uint64
tsEnd uint64
expectedColumns []string // column names
expectedEvols []string // evolution column names
expectedError bool
errorStr string
}{
{
name: "New evolutions at tsStartTime - should include latest evolution",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resource"},
expectedEvols: []string{"resource"},
},
{
name: "New evolutions after tsStartTime but less than tsEndTime - should include both",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resource", "resources_string"}, // sorted by ReleaseTime desc
expectedEvols: []string{"resource", "resources_string"},
},
{
name: "Columns without matching evolutions - should exclude them",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"], // no evolution for this
logsV2Columns["attributes_string"], // no evolution for this
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string"},
expectedEvols: []string{"resources_string"},
},
{
name: "New evolutions at tsEndTime - should not include new evolution",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 30, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string"},
expectedEvols: []string{"resources_string"},
},
{
name: "New evolutions after tsEndTime - should exclude new",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 2, 25, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string"},
expectedEvols: []string{"resources_string"},
},
{
name: "Empty columns array",
columns: []*schema.Column{},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{},
expectedEvols: []string{},
expectedError: true,
errorStr: "column resources_string not found",
},
{
name: "Duplicate evolutions - should use first encountered (oldest if sorted)",
columns: []*schema.Column{
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resource"},
expectedEvols: []string{"resource"}, // should use first one (older)
},
{
name: "Genuine Duplicate evolutions with new version- should consider both",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 0,
ReleaseTime: time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 1,
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
Version: 2,
ReleaseTime: time.Date(2024, 1, 20, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 1, 16, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string", "resource"},
expectedEvols: []string{"resources_string", "resource"}, // should use first one (older)
},
{
name: "Evolution exactly at tsEndTime",
columns: []*schema.Column{
logsV2Columns["resources_string"],
logsV2Columns["resource"],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC), // exactly at tsEnd
},
},
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{"resources_string"}, // resource excluded because After(tsEnd) is true
expectedEvols: []string{"resources_string"},
},
{
name: "Single evolution after tsStartTime - JSON body",
columns: []*schema.Column{
logsV2Columns[LogsV2BodyJSONColumn],
logsV2Columns[LogsV2BodyPromotedColumn],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: LogsV2BodyJSONColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: LogsV2BodyPromotedColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "user.name",
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{LogsV2BodyPromotedColumn, LogsV2BodyJSONColumn}, // sorted by ReleaseTime desc (newest first)
expectedEvols: []string{LogsV2BodyPromotedColumn, LogsV2BodyJSONColumn},
},
{
name: "No evolution after tsStartTime - JSON body",
columns: []*schema.Column{
logsV2Columns[LogsV2BodyJSONColumn],
logsV2Columns[LogsV2BodyPromotedColumn],
},
evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: LogsV2BodyJSONColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: LogsV2BodyPromotedColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
FieldName: "user.name",
ReleaseTime: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
},
},
tsStart: uint64(time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC).UnixNano()),
tsEnd: uint64(time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC).UnixNano()),
expectedColumns: []string{LogsV2BodyPromotedColumn},
expectedEvols: []string{LogsV2BodyPromotedColumn},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
resultColumns, resultEvols, err := selectEvolutionsForColumns(tc.columns, tc.evolutions, tc.tsStart, tc.tsEnd)
if tc.expectedError {
assert.Contains(t, err.Error(), tc.errorStr)
} else {
require.NoError(t, err)
assert.Equal(t, len(tc.expectedColumns), len(resultColumns), "column count mismatch")
assert.Equal(t, len(tc.expectedEvols), len(resultEvols), "evolution count mismatch")
resultColumnNames := make([]string, len(resultColumns))
for i, col := range resultColumns {
resultColumnNames[i] = col.Name
}
resultEvolNames := make([]string, len(resultEvols))
for i, evol := range resultEvols {
resultEvolNames[i] = evol.ColumnName
}
for i := range tc.expectedColumns {
assert.Equal(t, resultColumnNames[i], tc.expectedColumns[i], "expected column missing: "+tc.expectedColumns[i])
}
for i := range tc.expectedEvols {
assert.Equal(t, resultEvolNames[i], tc.expectedEvols[i], "expected evolution missing: "+tc.expectedEvols[i])
}
// Verify sorting: should be descending by ReleaseTime
for i := 0; i < len(resultEvols)-1; i++ {
assert.True(t, !resultEvols[i].ReleaseTime.Before(resultEvols[i+1].ReleaseTime),
"evolutions should be sorted descending by ReleaseTime")
}
}
})
}
}
func TestFieldForWithMaterialized(t *testing.T) {
ctx := context.Background()
materializedKey := &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Materialized: true,
Evolutions: []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
ColumnType: "Map(LowCardinality(String), String)",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC),
},
},
}
tests := []struct {
name string
start, end time.Time
expectedResult string
}{
{
name: "Map column in use (pre-evolution to JSON)",
start: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2024, 2, 2, 0, 0, 0, 0, time.UTC),
expectedResult: "`resource_string_service$$name`",
},
{
name: "Multi evolution - both columns (JSON + materialized)",
start: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
end: time.Date(2024, 4, 2, 0, 0, 0, 0, time.UTC),
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`==true, `resource_string_service$$name`, NULL)",
},
}
fm := NewFieldMapper()
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
start := uint64(tc.start.UnixNano())
end := uint64(tc.end.UnixNano())
result, err := fm.FieldFor(ctx, start, end, materializedKey)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
})
}
}

View File

@@ -1,9 +1,7 @@
package telemetrylogs
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -12,15 +10,12 @@ import (
// TestLikeAndILikeWithoutWildcards_Warns Tests that LIKE/ILIKE without wildcards add warnings and include docs URL
func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
keys := buildCompleteFieldKeyMap(releaseTime)
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
@@ -38,7 +33,7 @@ func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
for _, expr := range tests {
t.Run(expr, func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(expr, opts)
clause, err := querybuilder.PrepareWhereClause(expr, opts, 0, 0)
require.NoError(t, err)
require.NotNil(t, clause)
@@ -54,11 +49,9 @@ func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
keys := buildCompleteFieldKeyMap(releaseTime)
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
@@ -76,7 +69,7 @@ func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
for _, expr := range tests {
t.Run(expr, func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(expr, opts)
clause, err := querybuilder.PrepareWhereClause(expr, opts, 0, 0)
require.NoError(t, err)
require.NotNil(t, clause)

View File

@@ -1,10 +1,8 @@
package telemetrylogs
import (
"context"
"fmt"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -18,11 +16,9 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
// Define a comprehensive set of field keys to support all test cases
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
keys := buildCompleteFieldKeyMap(releaseTime)
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
@@ -165,7 +161,7 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
if tc.shouldPass {
if err != nil {

View File

@@ -1,11 +1,9 @@
package telemetrylogs
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -17,24 +15,19 @@ import (
// TestFilterExprLogs tests a comprehensive set of query patterns for logs search
func TestFilterExprLogs(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
ctx := context.Background()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
// Define a comprehensive set of field keys to support all test cases
keys := buildCompleteFieldKeyMap(releaseTime)
keys := buildCompleteFieldKeyMap()
opts := querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
FieldKeys: keys,
FullTextColumn: DefaultFullTextColumn,
JsonKeyToKey: GetBodyJSONKey,
StartNs: uint64(releaseTime.Add(-5 * time.Minute).UnixNano()),
EndNs: uint64(releaseTime.Add(5 * time.Minute).UnixNano()),
}
testCases := []struct {
@@ -473,7 +466,7 @@ func TestFilterExprLogs(t *testing.T) {
expectedErrorContains: "",
},
//fulltext with parenthesized expression
// fulltext with parenthesized expression
{
category: "FREETEXT with parentheses",
query: "error (status.code=500 OR status.code=503)",
@@ -2393,7 +2386,7 @@ func TestFilterExprLogs(t *testing.T) {
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
if tc.shouldPass {
if err != nil {
@@ -2433,8 +2426,7 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
cb := NewConditionBuilder(fm)
// Define a comprehensive set of field keys to support all test cases
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
keys := buildCompleteFieldKeyMap(releaseTime)
keys := buildCompleteFieldKeyMap()
keys["body"] = []*telemetrytypes.TelemetryFieldKey{
{
@@ -2450,7 +2442,6 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
}
opts := querybuilder.FilterExprVisitorOpts{
Context: context.Background(),
Logger: instrumentationtest.New().Logger(),
FieldMapper: fm,
ConditionBuilder: cb,
@@ -2513,7 +2504,7 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
if tc.shouldPass {
if err != nil {

View File

@@ -268,7 +268,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
}
// get column expression for the field - use array index directly to avoid pointer to loop variable
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &query.SelectFields[index], keys)
colExpr, err := b.fm.ColumnExpressionFor(ctx, &query.SelectFields[index], keys)
if err != nil {
return nil, err
}
@@ -277,6 +277,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
}
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
@@ -286,8 +287,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
// Add order by
for _, orderBy := range query.Order {
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &orderBy.Key.TelemetryFieldKey, keys)
colExpr, err := b.fm.ColumnExpressionFor(ctx, &orderBy.Key.TelemetryFieldKey, keys)
if err != nil {
return nil, err
}
@@ -353,7 +353,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Keep original column expressions so we can build the tuple
fieldNames := make([]string, 0, len(query.GroupBy))
for _, gb := range query.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
if err != nil {
return nil, err
}
@@ -368,7 +368,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, start, end, agg.Expression,
ctx, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
)
@@ -500,7 +500,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
var allGroupByArgs []any
for _, gb := range query.GroupBy {
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, start, end, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
expr, args, err := querybuilder.CollisionHandledFinalExpr(ctx, &gb.TelemetryFieldKey, b.fm, b.cb, keys, telemetrytypes.FieldDataTypeString, b.jsonKeyToKey)
if err != nil {
return nil, err
}
@@ -518,7 +518,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, start, end, aggExpr.Expression,
ctx, aggExpr.Expression,
rateInterval,
keys,
)
@@ -590,7 +590,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
// buildFilterCondition builds SQL condition from filter expression
func (b *logQueryStatementBuilder) addFilterCondition(
ctx context.Context,
_ context.Context,
sb *sqlbuilder.SelectBuilder,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
@@ -604,7 +604,6 @@ func (b *logQueryStatementBuilder) addFilterCondition(
if query.Filter != nil && query.Filter.Expression != "" {
// add filter expression
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
@@ -613,9 +612,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
StartNs: start,
EndNs: end,
})
}, start, end)
if err != nil {
return nil, err

View File

@@ -18,7 +18,7 @@ func resourceFilterStmtBuilder() qbtypes.StatementBuilder[qbtypes.LogAggregation
fm := resourcefilter.NewFieldMapper()
cb := resourcefilter.NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keysMap := buildCompleteFieldKeyMap(time.Now())
keysMap := buildCompleteFieldKeyMap()
for _, keys := range keysMap {
for _, key := range keys {
key.Signal = telemetrytypes.SignalLogs
@@ -37,14 +37,7 @@ func resourceFilterStmtBuilder() qbtypes.StatementBuilder[qbtypes.LogAggregation
}
func TestStatementBuilderTimeSeries(t *testing.T) {
// Create a test release time
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
cases := []struct {
startTs uint64
endTs uint64
name string
requestType qbtypes.RequestType
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
@@ -52,16 +45,14 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
expectedErr error
}{
{
startTs: releaseTimeNano + uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with limit and count distinct on service.name",
name: "Time series with limit",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{
{
Expression: "count_distinct(service.name)",
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
@@ -77,22 +68,20 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, countDistinct(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, countDistinct(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
{
startTs: releaseTimeNano - uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with OR b/w resource attr and attribute filter and count distinct on service.name",
name: "Time series with OR b/w resource attr and attribute filter",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.LogAggregation{
{
Expression: "count_distinct(service.name)",
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
@@ -108,14 +97,12 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, countDistinct(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, countDistinct(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1705224600), uint64(1705485600), "redis-manual", "GET", true, "1705226400000000000", uint64(1705224600), "1705485600000000000", uint64(1705485600), 10, "redis-manual", "GET", true, "1705226400000000000", uint64(1705224600), "1705485600000000000", uint64(1705485600)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE ((simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? AND multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL) OR (attributes_string['http.method'] = ? AND mapContains(attributes_string, 'http.method') = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name`",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "redis-manual", "GET", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "redis-manual", "GET", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
{
startTs: releaseTimeNano + uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with limit + custom order by",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -149,14 +136,12 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name`::String IS NOT NULL, resource.`service.name`::String, NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `service.name` ORDER BY `service.name` desc LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `service.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`service.name`) GLOBAL IN (SELECT `service.name` FROM __limit_cte) GROUP BY ts, `service.name` ORDER BY `service.name` desc, ts desc",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
{
startTs: releaseTimeNano + uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with group by on materialized column",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -183,12 +168,10 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists` = ?, `attribute_string_materialized$$key$$name`, NULL)) AS `materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND true AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`materialized.key.name`) GLOBAL IN (SELECT `materialized.key.name` FROM __limit_cte) GROUP BY ts, `materialized.key.name`",
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), true, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, true, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10, true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
},
{
startTs: releaseTimeNano + uint64(24*time.Hour.Nanoseconds()),
endTs: releaseTimeNano + uint64(48*time.Hour.Nanoseconds()),
name: "Time series with materialised column using or with regex operator",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -206,19 +189,14 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (true OR true) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists` = ?) OR (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists` = ?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts",
Args: []any{uint64(1705397400), uint64(1705485600), "redis.*", true, "memcached", true, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
Args: []any{uint64(1747945619), uint64(1747983448), "redis.*", true, "memcached", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
},
expectedErr: nil,
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keysMap := buildCompleteFieldKeyMap(releaseTime)
mockMetadataStore.KeysMap = keysMap
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
@@ -240,7 +218,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, c.startTs, c.endTs, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
@@ -337,13 +315,9 @@ func TestStatementBuilderListQuery(t *testing.T) {
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fm := NewFieldMapper()
// Create a test release time
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
cb := NewConditionBuilder(fm)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
@@ -364,7 +338,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
@@ -481,12 +455,9 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fm := NewFieldMapper()
// Create a test release time
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
cb := NewConditionBuilder(fm)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
@@ -504,10 +475,12 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
GetBodyJSONKey,
)
//
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
@@ -558,12 +531,9 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap()
fm := NewFieldMapper()
// Create a test release time
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
cb := NewConditionBuilder(fm)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
@@ -584,7 +554,7 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErrContains != "" {
require.Error(t, err)
@@ -656,10 +626,9 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
},
}
ctx := context.Background()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
fm := NewFieldMapper()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMapCollision()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil)
@@ -680,7 +649,7 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
if c.expectedErr != nil {
require.Error(t, err)
@@ -698,9 +667,6 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
}
func TestAdjustKey(t *testing.T) {
// Create a test release time
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
cases := []struct {
name string
inputKey telemetrytypes.TelemetryFieldKey
@@ -714,7 +680,7 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
keysMap: buildCompleteFieldKeyMap(),
expectedKey: IntrinsicFields["severity_text"],
},
{
@@ -751,7 +717,7 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
keysMap: buildCompleteFieldKeyMap(),
expectedKey: telemetrytypes.TelemetryFieldKey{
Name: "severity_number",
FieldContext: telemetrytypes.FieldContextBody,
@@ -765,8 +731,8 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
expectedKey: *buildCompleteFieldKeyMap(releaseTime)["service.name"][0],
keysMap: buildCompleteFieldKeyMap(),
expectedKey: *buildCompleteFieldKeyMap()["service.name"][0],
},
{
name: "single matching key with incorrect context specified - no override",
@@ -775,7 +741,7 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
keysMap: buildCompleteFieldKeyMap(),
expectedKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextAttribute,
@@ -789,8 +755,8 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
expectedKey: *buildCompleteFieldKeyMap(releaseTime)["service.name"][0],
keysMap: buildCompleteFieldKeyMap(),
expectedKey: *buildCompleteFieldKeyMap()["service.name"][0],
},
{
name: "multiple matching keys - all materialized",
@@ -799,7 +765,7 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
keysMap: buildCompleteFieldKeyMap(),
expectedKey: telemetrytypes.TelemetryFieldKey{
Name: "multi.mat.key",
FieldDataType: telemetrytypes.FieldDataTypeString,
@@ -813,7 +779,7 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
keysMap: buildCompleteFieldKeyMap(),
expectedKey: telemetrytypes.TelemetryFieldKey{
Name: "mixed.materialization.key",
FieldDataType: telemetrytypes.FieldDataTypeString,
@@ -827,8 +793,8 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
expectedKey: *buildCompleteFieldKeyMap(releaseTime)["mixed.materialization.key"][0],
keysMap: buildCompleteFieldKeyMap(),
expectedKey: *buildCompleteFieldKeyMap()["mixed.materialization.key"][0],
},
{
name: "no matching keys - unknown field",
@@ -837,7 +803,7 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
keysMap: buildCompleteFieldKeyMap(),
expectedKey: telemetrytypes.TelemetryFieldKey{
Name: "unknown.field",
FieldContext: telemetrytypes.FieldContextUnspecified,
@@ -852,7 +818,7 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
keysMap: buildCompleteFieldKeyMap(),
expectedKey: telemetrytypes.TelemetryFieldKey{
Name: "unknown.field",
FieldContext: telemetrytypes.FieldContextAttribute,
@@ -867,8 +833,8 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
expectedKey: *buildCompleteFieldKeyMap(releaseTime)["mat.key"][0],
keysMap: buildCompleteFieldKeyMap(),
expectedKey: *buildCompleteFieldKeyMap()["mat.key"][0],
},
{
name: "non-materialized field",
@@ -877,8 +843,8 @@ func TestAdjustKey(t *testing.T) {
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
keysMap: buildCompleteFieldKeyMap(releaseTime),
expectedKey: *buildCompleteFieldKeyMap(releaseTime)["user.id"][0],
keysMap: buildCompleteFieldKeyMap(),
expectedKey: *buildCompleteFieldKeyMap()["user.id"][0],
},
}

View File

@@ -2,7 +2,6 @@ package telemetrylogs
import (
"strings"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -19,7 +18,7 @@ func limitString(s string, maxLen int) string {
}
// Function to build a complete field key map for testing all scenarios
func buildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytypes.TelemetryFieldKey {
func buildCompleteFieldKeyMap() map[string][]*telemetrytypes.TelemetryFieldKey {
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
"service.name": {
{
@@ -944,9 +943,6 @@ func buildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
for _, keys := range keysMap {
for _, key := range keys {
key.Signal = telemetrytypes.SignalLogs
if key.FieldContext == telemetrytypes.FieldContextResource {
key.Evolutions = mockEvolutionData(releaseTime)
}
}
}
return keysMap
@@ -1011,24 +1007,3 @@ func buildCompleteFieldKeyMapCollision() map[string][]*telemetrytypes.TelemetryF
}
return keysMap
}
func mockEvolutionData(releaseTime time.Time) []*telemetrytypes.EvolutionEntry {
return []*telemetrytypes.EvolutionEntry{
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resources_string",
FieldContext: telemetrytypes.FieldContextResource,
ColumnType: "Map(LowCardinality(String), String)",
FieldName: "__all__",
ReleaseTime: time.Unix(0, 0),
},
{
Signal: telemetrytypes.SignalLogs,
ColumnName: "resource",
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextResource,
FieldName: "__all__",
ReleaseTime: releaseTime,
},
}
}

View File

@@ -21,11 +21,12 @@ func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
func (c *conditionBuilder) ConditionFor(
ctx context.Context,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
_ uint64,
_ uint64,
) (string, error) {
switch operator {
@@ -38,13 +39,13 @@ func (c *conditionBuilder) ConditionFor(
value = querybuilder.FormatValueForContains(value)
}
columns, err := c.fm.ColumnFor(ctx, tsStart, tsEnd, key)
column, err := c.fm.ColumnFor(ctx, key)
if err != nil {
// if we don't have a column, we can't build a condition for related values
return "", nil
}
fieldExpression, err := c.fm.FieldFor(ctx, tsStart, tsEnd, key)
tblFieldName, err := c.fm.FieldFor(ctx, key)
if err != nil {
// if we don't have a table field name, we can't build a condition for related values
return "", nil
@@ -56,7 +57,7 @@ func (c *conditionBuilder) ConditionFor(
return "", nil
}
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
tblFieldName, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, tblFieldName, operator)
// key must exists to apply main filter
expr := `if(mapContains(%s, %s), %s, true)`
@@ -67,29 +68,29 @@ func (c *conditionBuilder) ConditionFor(
switch operator {
// regular operators
case qbtypes.FilterOperatorEqual:
cond = sb.E(fieldExpression, value)
cond = sb.E(tblFieldName, value)
case qbtypes.FilterOperatorNotEqual:
cond = sb.NE(fieldExpression, value)
cond = sb.NE(tblFieldName, value)
// like and not like
case qbtypes.FilterOperatorLike:
cond = sb.Like(fieldExpression, value)
cond = sb.Like(tblFieldName, value)
case qbtypes.FilterOperatorNotLike:
cond = sb.NotLike(fieldExpression, value)
cond = sb.NotLike(tblFieldName, value)
case qbtypes.FilterOperatorILike:
cond = sb.ILike(fieldExpression, value)
cond = sb.ILike(tblFieldName, value)
case qbtypes.FilterOperatorNotILike:
cond = sb.NotILike(fieldExpression, value)
cond = sb.NotILike(tblFieldName, value)
case qbtypes.FilterOperatorContains:
cond = sb.ILike(fieldExpression, fmt.Sprintf("%%%s%%", value))
cond = sb.ILike(tblFieldName, fmt.Sprintf("%%%s%%", value))
case qbtypes.FilterOperatorNotContains:
cond = sb.NotILike(fieldExpression, fmt.Sprintf("%%%s%%", value))
cond = sb.NotILike(tblFieldName, fmt.Sprintf("%%%s%%", value))
case qbtypes.FilterOperatorRegexp:
cond = fmt.Sprintf(`match(%s, %s)`, fieldExpression, sb.Var(value))
cond = fmt.Sprintf(`match(%s, %s)`, tblFieldName, sb.Var(value))
case qbtypes.FilterOperatorNotRegexp:
cond = fmt.Sprintf(`NOT match(%s, %s)`, fieldExpression, sb.Var(value))
cond = fmt.Sprintf(`NOT match(%s, %s)`, tblFieldName, sb.Var(value))
// in and not in
case qbtypes.FilterOperatorIn:
@@ -100,7 +101,7 @@ func (c *conditionBuilder) ConditionFor(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.E(fieldExpression, value))
conditions = append(conditions, sb.E(tblFieldName, value))
}
cond = sb.Or(conditions...)
case qbtypes.FilterOperatorNotIn:
@@ -111,7 +112,7 @@ func (c *conditionBuilder) ConditionFor(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
conditions = append(conditions, sb.NE(fieldExpression, value))
conditions = append(conditions, sb.NE(tblFieldName, value))
}
cond = sb.And(conditions...)
@@ -119,12 +120,12 @@ func (c *conditionBuilder) ConditionFor(
// in the query builder, `exists` and `not exists` are used for
// key membership checks, so depending on the column type, the condition changes
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
switch columns[0].Type {
switch column.Type {
case schema.MapColumnType{
KeyType: schema.LowCardinalityColumnType{ElementType: schema.ColumnTypeString},
ValueType: schema.ColumnTypeString,
}:
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", columns[0].Name, key.Name)
leftOperand := fmt.Sprintf("mapContains(%s, '%s')", column.Name, key.Name)
if operator == qbtypes.FilterOperatorExists {
cond = sb.E(leftOperand, true)
} else {
@@ -133,5 +134,5 @@ func (c *conditionBuilder) ConditionFor(
}
}
return fmt.Sprintf(expr, columns[0].Name, sb.Var(key.Name), cond), nil
return fmt.Sprintf(expr, column.Name, sb.Var(key.Name), cond), nil
}

View File

@@ -53,7 +53,7 @@ func TestConditionFor(t *testing.T) {
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
cond, err := conditionBuilder.ConditionFor(ctx, 0, 0, &tc.key, tc.operator, tc.value, sb)
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
sb.Where(cond)
if tc.expectedError != nil {

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