Compare commits

..

1 Commits

Author SHA1 Message Date
vikrantgupta25
d922561dce feat(user): accept custom roles in user invite 2026-06-22 00:09:25 +05:30
18 changed files with 446 additions and 129 deletions

View File

@@ -141,10 +141,6 @@ querier:
flux_interval: 5m
# The maximum number of concurrent queries for missing ranges.
max_concurrent_queries: 4
# When filtering logs by trace_id, clamp the query window to the trace time
# range with padding to include slightly delayed log exports. Logs only; set
# to 0 to disable.
log_trace_id_window_padding: 5m
##################### TelemetryStore #####################
telemetrystore:

View File

@@ -655,6 +655,29 @@ components:
refreshToken:
type: string
type: object
AuthtypesPostableUser:
properties:
displayName:
type: string
email:
type: string
frontendBaseUrl:
type: string
roles:
items:
$ref: '#/components/schemas/AuthtypesPostableUserRole'
type: array
required:
- email
- roles
type: object
AuthtypesPostableUserRole:
properties:
id:
type: string
required:
- id
type: object
AuthtypesRelation:
enum:
- create
@@ -10127,7 +10150,7 @@ paths:
- global
/api/v1/invite:
post:
deprecated: false
deprecated: true
description: This endpoint creates an invite for a user
operationId: CreateInvite
requestBody:
@@ -10190,7 +10213,7 @@ paths:
- users
/api/v1/invite/bulk:
post:
deprecated: false
deprecated: true
description: This endpoint creates a bulk invite for a user
operationId: CreateBulkInvite
requestBody:
@@ -12960,7 +12983,7 @@ paths:
- tracedetail
/api/v1/user:
get:
deprecated: false
deprecated: true
description: This endpoint lists all users
operationId: ListUsersDeprecated
responses:
@@ -13053,7 +13076,7 @@ paths:
tags:
- users
get:
deprecated: false
deprecated: true
description: This endpoint returns the user by id
operationId: GetUserDeprecated
parameters:
@@ -13110,7 +13133,7 @@ paths:
tags:
- users
put:
deprecated: false
deprecated: true
description: This endpoint updates the user by id
operationId: UpdateUserDeprecated
parameters:
@@ -13179,7 +13202,7 @@ paths:
- users
/api/v1/user/me:
get:
deprecated: false
deprecated: true
description: This endpoint returns the user I belong to
operationId: GetMyUserDeprecated
responses:
@@ -20595,6 +20618,68 @@ paths:
summary: List users v2
tags:
- users
post:
deprecated: false
description: This endpoint creates a user for the organization
operationId: CreateUser
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableUser'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create user
tags:
- users
/api/v2/users/{id}:
get:
deprecated: false

View File

@@ -2242,6 +2242,32 @@ export interface AuthtypesPostableRotateTokenDTO {
refreshToken?: string;
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesPostableUserDTO {
/**
* @type string
*/
displayName?: string;
/**
* @type string
*/
email: string;
/**
* @type string
*/
frontendBaseUrl?: string;
/**
* @type array
*/
roles: AuthtypesPostableUserRoleDTO[];
}
export interface AuthtypesRoleDTO {
/**
* @type string
@@ -10744,6 +10770,14 @@ export type ListUsers200 = {
status: string;
};
export type CreateUser201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type GetUserPathParameters = {
id: string;
};

View File

@@ -18,9 +18,11 @@ import type {
} from 'react-query';
import type {
AuthtypesPostableUserDTO,
CreateInvite201,
CreateResetPasswordToken201,
CreateResetPasswordTokenPathParameters,
CreateUser201,
DeleteUserPathParameters,
GetMyUser200,
GetMyUserDeprecated200,
@@ -169,6 +171,7 @@ export const invalidateGetResetPasswordTokenDeprecated = async (
/**
* This endpoint creates an invite for a user
* @deprecated
* @summary Create invite
*/
export const createInvite = (
@@ -230,6 +233,7 @@ export type CreateInviteMutationBody =
export type CreateInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create invite
*/
export const useCreateInvite = <
@@ -252,6 +256,7 @@ export const useCreateInvite = <
};
/**
* This endpoint creates a bulk invite for a user
* @deprecated
* @summary Create bulk invite
*/
export const createBulkInvite = (
@@ -313,6 +318,7 @@ export type CreateBulkInviteMutationBody =
export type CreateBulkInviteMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create bulk invite
*/
export const useCreateBulkInvite = <
@@ -418,6 +424,7 @@ export const useResetPassword = <
};
/**
* This endpoint lists all users
* @deprecated
* @summary List users
*/
export const listUsersDeprecated = (signal?: AbortSignal) => {
@@ -463,6 +470,7 @@ export type ListUsersDeprecatedQueryResult = NonNullable<
export type ListUsersDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary List users
*/
@@ -486,6 +494,7 @@ export function useListUsersDeprecated<
}
/**
* @deprecated
* @summary List users
*/
export const invalidateListUsersDeprecated = async (
@@ -581,6 +590,7 @@ export const useDeleteUser = <
};
/**
* This endpoint returns the user by id
* @deprecated
* @summary Get user
*/
export const getUserDeprecated = (
@@ -640,6 +650,7 @@ export type GetUserDeprecatedQueryResult = NonNullable<
export type GetUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Get user
*/
@@ -666,6 +677,7 @@ export function useGetUserDeprecated<
}
/**
* @deprecated
* @summary Get user
*/
export const invalidateGetUserDeprecated = async (
@@ -683,6 +695,7 @@ export const invalidateGetUserDeprecated = async (
/**
* This endpoint updates the user by id
* @deprecated
* @summary Update user
*/
export const updateUserDeprecated = (
@@ -755,6 +768,7 @@ export type UpdateUserDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Update user
*/
export const useUpdateUserDeprecated = <
@@ -783,6 +797,7 @@ export const useUpdateUserDeprecated = <
};
/**
* This endpoint returns the user I belong to
* @deprecated
* @summary Get my user
*/
export const getMyUserDeprecated = (signal?: AbortSignal) => {
@@ -828,6 +843,7 @@ export type GetMyUserDeprecatedQueryResult = NonNullable<
export type GetMyUserDeprecatedQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Get my user
*/
@@ -851,6 +867,7 @@ export function useGetMyUserDeprecated<
}
/**
* @deprecated
* @summary Get my user
*/
export const invalidateGetMyUserDeprecated = async (
@@ -1209,6 +1226,89 @@ export const invalidateListUsers = async (
return queryClient;
};
/**
* This endpoint creates a user for the organization
* @summary Create user
*/
export const createUser = (
authtypesPostableUserDTO?: BodyType<AuthtypesPostableUserDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateUser201>({
url: `/api/v2/users`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableUserDTO,
signal,
});
};
export const getCreateUserMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUser>>,
TError,
{ data?: BodyType<AuthtypesPostableUserDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createUser>>,
TError,
{ data?: BodyType<AuthtypesPostableUserDTO> },
TContext
> => {
const mutationKey = ['createUser'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createUser>>,
{ data?: BodyType<AuthtypesPostableUserDTO> }
> = (props) => {
const { data } = props ?? {};
return createUser(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateUserMutationResult = NonNullable<
Awaited<ReturnType<typeof createUser>>
>;
export type CreateUserMutationBody =
| BodyType<AuthtypesPostableUserDTO>
| undefined;
export type CreateUserMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create user
*/
export const useCreateUser = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUser>>,
TError,
{ data?: BodyType<AuthtypesPostableUserDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createUser>>,
TError,
{ data?: BodyType<AuthtypesPostableUserDTO> },
TContext
> => {
return useMutation(getCreateUserMutationOptions(options));
};
/**
* This endpoint returns the user by id
* @summary Get user by user id

View File

@@ -21,7 +21,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
@@ -37,7 +37,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Response: nil,
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
@@ -54,7 +54,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
@@ -88,7 +88,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
Deprecated: true,
SecuritySchemes: []handler.OpenAPISecurityScheme{{Name: authtypes.IdentNProviderTokenizer.StringValue()}},
})).Methods(http.MethodGet).GetError(); err != nil {
return err
@@ -111,6 +111,23 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/me", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.UpdateMyUser), handler.OpenAPIDef{
ID: "UpdateMyUserV2",
Tags: []string{"users"},
@@ -139,7 +156,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
@@ -173,7 +190,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
Deprecated: true,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err

View File

@@ -25,6 +25,42 @@ func NewHandler(setter root.Setter, getter root.Getter) root.Handler {
return &handler{setter: setter, getter: getter}
}
func (handler *handler) CreateUser(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
req := new(authtypes.PostableUser)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
user, err := types.NewUser(req.DisplayName, req.Email, valuer.MustNewUUID(claims.OrgID), types.UserStatusPendingInvite)
if err != nil {
render.Error(rw, err)
return
}
roleIDs := make([]valuer.UUID, 0, len(req.Roles))
for _, role := range req.Roles {
roleIDs = append(roleIDs, role.ID)
}
user, err = handler.setter.CreateUserInvite(ctx, valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), req.FrontendBaseUrl, user, root.WithRoleIDs(roleIDs))
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, types.Identifiable{ID: user.ID})
}
func (handler *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

View File

@@ -215,6 +215,68 @@ func (module *setter) CreateUser(ctx context.Context, user *types.User, opts ...
return nil
}
// CreateUserInvite creates a pending invite user with the roles given via opts and emails them a
// link to set their password. The grant is deferred until the invite is accepted (see
// UpdatePasswordByResetPasswordToken), so unlike CreateUser it never grants the roles here.
func (module *setter) CreateUserInvite(ctx context.Context, identityID valuer.UUID, identityEmail valuer.Email, frontendBaseURL string, user *types.User, opts ...root.CreateUserOption) (*types.User, error) {
createUserOpts := root.NewCreateUserOptions(opts...)
// roles can be supplied either by name or by id, resolve the ids to names so both
// converge. ListByOrgIDAndIDs also validates that the roles exist in the org.
roleNames := createUserOpts.RoleNames
if len(createUserOpts.RoleIDs) > 0 {
roles, err := module.authz.ListByOrgIDAndIDs(ctx, user.OrgID, createUserOpts.RoleIDs)
if err != nil {
return nil, err
}
for _, role := range roles {
roleNames = append(roleNames, role.Name)
}
}
var resetPasswordToken *types.ResetPasswordToken
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.createUserWithoutGrant(ctx, user, root.WithRoleNames(roleNames), root.WithFactorPassword(createUserOpts.FactorPassword)); err != nil {
return err
}
token, err := module.GetOrCreateResetPasswordToken(ctx, user.ID)
if err != nil {
return err
}
resetPasswordToken = token
return nil
}); err != nil {
return nil, err
}
module.analytics.TrackUser(ctx, user.OrgID.String(), identityID.String(), "Invite Sent", map[string]any{
"invitee_email": user.Email,
"invitee_role": roleNames,
})
if frontendBaseURL == "" {
module.settings.Logger().InfoContext(ctx, "frontend base url is not provided, skipping email", slog.Any("invitee_email", user.Email))
return user, nil
}
resetLink := resetPasswordToken.FactorPasswordResetLink(frontendBaseURL)
tokenLifetime := module.config.Password.Invite.MaxTokenLifetime
humanizedTokenLifetime := strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(tokenLifetime), "", ""))
if err := module.emailing.SendHTML(ctx, user.Email.String(), "You're Invited to Join SigNoz", emailtypes.TemplateNameInvitationEmail, map[string]any{
"inviter_email": identityEmail.StringValue(),
"link": resetLink,
"Expiry": humanizedTokenLifetime,
}); err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to send invite email", errors.Attr(err))
}
return user, nil
}
func (module *setter) UpdateUserDeprecated(ctx context.Context, orgID valuer.UUID, id string, user *types.DeprecatedUser) (*types.DeprecatedUser, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {

View File

@@ -8,6 +8,7 @@ import (
type createUserOptions struct {
FactorPassword *types.FactorPassword
RoleNames []string
RoleIDs []valuer.UUID
}
type CreateUserOption func(*createUserOptions)
@@ -24,6 +25,12 @@ func WithRoleNames(roleNames []string) CreateUserOption {
}
}
func WithRoleIDs(roleIDs []valuer.UUID) CreateUserOption {
return func(o *createUserOptions) {
o.RoleIDs = roleIDs
}
}
func NewCreateUserOptions(opts ...CreateUserOption) *createUserOptions {
o := &createUserOptions{
FactorPassword: nil,

View File

@@ -45,6 +45,9 @@ type Setter interface {
// invite
CreateBulkInvite(ctx context.Context, orgID valuer.UUID, identityID valuer.UUID, identityEmail valuer.Email, bulkInvites *types.PostableBulkInviteRequest) ([]*types.Invite, error)
// Creates a pending invite user with the roles given via opts and emails them the invite link.
CreateUserInvite(ctx context.Context, identityID valuer.UUID, identityEmail valuer.Email, frontendBaseURL string, user *types.User, opts ...CreateUserOption) (*types.User, error)
// Roles
UpdateUserRoles(ctx context.Context, orgID, userID valuer.UUID, finalRoleNames []string) error
AddUserRole(ctx context.Context, orgID, userID valuer.UUID, roleName string) error
@@ -107,6 +110,7 @@ type Handler interface {
// users
ListUsersDeprecated(http.ResponseWriter, *http.Request)
ListUsers(http.ResponseWriter, *http.Request)
CreateUser(http.ResponseWriter, *http.Request)
UpdateUserDeprecated(http.ResponseWriter, *http.Request)
UpdateUser(http.ResponseWriter, *http.Request)
DeleteUser(http.ResponseWriter, *http.Request)

View File

@@ -31,8 +31,6 @@ type builderQuery[T any] struct {
fromMS uint64
toMS uint64
kind qbtypes.RequestType
logTraceIDWindowPaddingMS uint64
}
var _ qbtypes.Query = (*builderQuery[any])(nil)
@@ -45,18 +43,16 @@ func newBuilderQuery[T any](
tr qbtypes.TimeRange,
kind qbtypes.RequestType,
variables map[string]qbtypes.VariableItem,
logTraceIDWindowPaddingMS uint64,
) *builderQuery[T] {
return &builderQuery[T]{
logger: logger,
telemetryStore: telemetryStore,
stmtBuilder: stmtBuilder,
spec: spec,
variables: variables,
fromMS: tr.From,
toMS: tr.To,
kind: kind,
logTraceIDWindowPaddingMS: logTraceIDWindowPaddingMS,
logger: logger,
telemetryStore: telemetryStore,
stmtBuilder: stmtBuilder,
spec: spec,
variables: variables,
fromMS: tr.From,
toMS: tr.To,
kind: kind,
}
}
@@ -281,20 +277,9 @@ func (q *builderQuery[T]) narrowWindowByTraceID(ctx context.Context, fromMS, toM
return fromMS, toMS, true, ""
}
// Logs can be flushed slightly after the span ends. The trace
// time range comes from the spans table, so for logs we widen it by the
// configured padding before clamping. Keep the actual recorded bounds for
// the user-facing warning so it reports where the trace truly lies, not the
// padded range.
actualStartMS, actualEndMS := traceStartMS, traceEndMS
if q.spec.Signal == telemetrytypes.SignalLogs {
traceStartMS -= q.logTraceIDWindowPaddingMS
traceEndMS += q.logTraceIDWindowPaddingMS
}
if traceStartMS > toMS || traceEndMS < fromMS {
traceStartUTC := time.UnixMilli(int64(actualStartMS)).UTC().Format(time.RFC3339)
traceEndUTC := time.UnixMilli(int64(actualEndMS)).UTC().Format(time.RFC3339)
traceStartUTC := time.UnixMilli(int64(traceStartMS)).UTC().Format(time.RFC3339)
traceEndUTC := time.UnixMilli(int64(traceEndMS)).UTC().Format(time.RFC3339)
return fromMS, toMS, false, fmt.Sprintf(traceOutsideRangeWarn, q.spec.Name, traceStartUTC, traceEndUTC)
}
if traceStartMS > fromMS {

View File

@@ -23,8 +23,6 @@ type Config struct {
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
// SkipResourceFingerprint configures when the resource fingerprint subquery is skipped in favor of main-table filtering.
SkipResourceFingerprint SkipResourceFingerprint `yaml:"skip_resource_fingerprint" mapstructure:"skip_resource_fingerprint"`
// LogTraceIDWindowPadding is the padding added to narrowed down timerange from trace summary to logs with trace_id filter.
LogTraceIDWindowPadding time.Duration `yaml:"log_trace_id_window_padding" mapstructure:"log_trace_id_window_padding"`
}
// NewConfigFactory creates a new config factory for querier.
@@ -42,7 +40,6 @@ func newConfig() factory.Config {
Enabled: false,
Threshold: 100000,
},
LogTraceIDWindowPadding: 5 * time.Minute,
}
}
@@ -60,9 +57,6 @@ func (c Config) Validate() error {
if c.SkipResourceFingerprint.Enabled && c.SkipResourceFingerprint.Threshold == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "skip_resource_fingerprint.threshold must be > 0 when enabled")
}
if c.LogTraceIDWindowPadding < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "log_trace_id_window_padding must not be negative, got %v", c.LogTraceIDWindowPadding)
}
return nil
}

View File

@@ -35,20 +35,19 @@ var (
)
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
bucketCache BucketCache
liveDataRefresh time.Duration
logTraceIDWindowPaddingMS uint64
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder
bucketCache BucketCache
liveDataRefresh time.Duration
}
var _ Querier = (*querier)(nil)
@@ -66,24 +65,22 @@ func New(
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
bucketCache BucketCache,
flagger flagger.Flagger,
logTraceIDWindowPadding time.Duration,
) *querier {
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
return &querier{
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
meterStmtBuilder: meterStmtBuilder,
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
bucketCache: bucketCache,
liveDataRefresh: 5 * time.Second,
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
meterStmtBuilder: meterStmtBuilder,
traceOperatorStmtBuilder: traceOperatorStmtBuilder,
bucketCache: bucketCache,
liveDataRefresh: 5 * time.Second,
}
}
@@ -176,7 +173,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, 0)
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
@@ -186,7 +183,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, q.logTraceIDWindowPaddingMS)
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars)
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -203,9 +200,9 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
if spec.Source == telemetrytypes.SourceMeter {
event.Source = telemetrytypes.SourceMeter.StringValue()
bq = newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars, 0)
bq = newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
} else {
bq = newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars, 0)
bq = newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars)
}
queries[spec.Name] = bq
@@ -511,7 +508,7 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
"id": {
Value: updatedLogID,
},
}, q.logTraceIDWindowPaddingMS)
})
queries[spec.Name] = bq
qbResp, qbErr := q.run(ctx, orgID, queries, req, nil, event, nil)
@@ -807,7 +804,7 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
return newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, 0)
return newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
case *builderQuery[qbtypes.LogAggregation]:
specCopy := qt.spec.Copy()
@@ -817,16 +814,16 @@ func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtyp
if qt.spec.Source == telemetrytypes.SourceAudit {
shiftStmtBuilder = q.auditStmtBuilder
}
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.logTraceIDWindowPaddingMS)
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
case *builderQuery[qbtypes.MetricAggregation]:
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
if qt.spec.Source == telemetrytypes.SourceMeter {
return newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, 0)
return newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
}
return newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, 0)
return newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables)
case *traceOperatorQuery:
specCopy := qt.spec.Copy()
return &traceOperatorQuery{

View File

@@ -54,7 +54,6 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
0,
)
req := &qbtypes.QueryRangeRequest{
@@ -125,7 +124,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
0,
)
req := &qbtypes.QueryRangeRequest{

View File

@@ -192,6 +192,5 @@ func newProvider(
traceOperatorStmtBuilder,
bucketCache,
flagger,
cfg.LogTraceIDWindowPadding,
), nil
}

View File

@@ -3,7 +3,6 @@ package rules
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -55,7 +54,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flagger,
0,
), metadataStore
}
@@ -109,7 +107,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
5*time.Minute, // logTraceIDWindowPadding
)
}
@@ -157,6 +154,5 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
0,
)
}

View File

@@ -2,6 +2,7 @@ package authtypes
import (
"context"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -28,6 +29,38 @@ type UserRole struct {
Role *Role `bun:"rel:belongs-to,join:role_id=id" json:"role" required:"true"`
}
type UserWithRoles struct {
*types.User
UserRoles []*UserRole `json:"userRoles"`
}
type PostableUser struct {
DisplayName string `json:"displayName"`
Email valuer.Email `json:"email" required:"true"`
FrontendBaseUrl string `json:"frontendBaseUrl"`
Roles []*PostableUserRole `json:"roles" required:"true" nullable:"false"`
}
type PostableUserRole struct {
ID valuer.UUID `json:"id" required:"true"`
}
func (p *PostableUser) UnmarshalJSON(data []byte) error {
type Alias PostableUser
var temp Alias
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
if temp.Roles == nil {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "roles is required")
}
*p = PostableUser(temp)
return nil
}
func newUserRole(userID valuer.UUID, roleID valuer.UUID) *UserRole {
return &UserRole{
ID: valuer.GenerateUUID(),
@@ -48,11 +81,6 @@ func NewUserRoles(userID valuer.UUID, roles []*Role) []*UserRole {
return userRoles
}
type UserWithRoles struct {
*types.User
UserRoles []*UserRole `json:"userRoles"`
}
type UserRoleStore interface {
// create user roles in bulk
CreateUserRoles(ctx context.Context, userRoles []*UserRole) error

View File

@@ -2306,11 +2306,9 @@ def test_logs_list_filter_by_trace_id(
"""
Tests that filtering logs by trace_id uses the trace_summary lookup to
narrow the query window before scanning the logs table:
1. Returns the matching logs (narrow window, single bucket), including a log
flushed shortly after the span ends — kept by the configured padding.
1. Returns the matching log (narrow window, single bucket).
2. Does not return duplicate logs when the query window should span multiple
exponential buckets (>1 h). The window is clamped to the trace's recorded
range widened by the padding, so the post-span log survives the clamp.
exponential buckets (>1 h). But is clamped to the timerange of trace.
3. Returns no results when the query window does not contain the trace.
4. Logs carrying a trace_id whose trace is NOT in trace_summary (e.g.
traces disabled) are still returned — the lookup miss must not
@@ -2368,9 +2366,6 @@ def test_logs_list_filter_by_trace_id(
# Insert logs:
# - one with the target trace_id, at a timestamp within the trace's
# recorded window (now-10s..now-5s, padded ±1s).
# - one with the target trace_id flushed ~3s AFTER the span's recorded end
# (now-2s). This is outside the ±1s base pad but inside the multi-minute
# log_trace_id_window_padding, so it must still be returned.
# - one with an orphan trace_id whose trace was never ingested — used to
# verify the lookup miss does NOT short-circuit logs queries.
insert_logs(
@@ -2384,15 +2379,6 @@ def test_logs_list_filter_by_trace_id(
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={"http.method": "POST"},
body="log flushed after the span ends, within padding window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
@@ -2443,31 +2429,23 @@ def test_logs_list_filter_by_trace_id(
now_ms = int(now.timestamp() * 1000)
inside_window_body = "log inside the target trace window"
post_span_body = "log flushed after the span ends, within padding window"
# --- Test 1: narrow window (single bucket, <1 h) ---
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
narrow_rows, narrow_warnings = _query(narrow_start_ms, now_ms, target_trace_id)
assert len(narrow_rows) == 2, f"Expected 2 logs for trace_id filter (narrow window), got {len(narrow_rows)}"
assert {r["data"]["trace_id"] for r in narrow_rows} == {target_trace_id}
narrow_bodies = {r["data"]["body"] for r in narrow_rows}
assert inside_window_body in narrow_bodies
assert post_span_body in narrow_bodies, "post-span log should be returned within the padding window"
assert len(narrow_rows) == 1, f"Expected 1 log for trace_id filter (narrow window), got {len(narrow_rows)}"
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
assert narrow_rows[0]["data"]["span_id"] == target_root_span_id
assert not any(outside_range_msg in m for m in narrow_warnings), f"Did not expect outside-range warning, got {narrow_warnings}"
# --- Test 2: wide window (>1 h, clamp to the padded timerange from trace_summary) ---
# Should return exactly the two target logs — no duplicates from multi-bucket
# scan, and the post-span log survives the clamp only because of the padding.
# --- Test 2: wide window (>1 h, clamp to the timerange from trace_summary) ---
# Should still return exactly one log — no duplicates from multi-bucket scan.
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_rows, wide_warnings = _query(wide_start_ms, now_ms, target_trace_id)
assert len(wide_rows) == 2, f"Expected 2 logs for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression or padding not applied"
assert {r["data"]["trace_id"] for r in wide_rows} == {target_trace_id}
wide_bodies = {r["data"]["body"] for r in wide_rows}
assert inside_window_body in wide_bodies
assert post_span_body in wide_bodies, "post-span log should survive the clamp because of the padding"
assert len(wide_rows) == 1, f"Expected 1 log for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression"
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
assert wide_rows[0]["data"]["span_id"] == target_root_span_id
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
# --- Test 3: window that does not contain the trace returns no results + warning ---

View File

@@ -15,6 +15,7 @@ from fixtures.querier import (
build_builder_query,
find_named_result,
get_all_warnings,
get_error_message,
index_series_by_label,
make_query_request,
)