mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-09 02:20:26 +01:00
Compare commits
3 Commits
main
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9622c867a2 | ||
|
|
a2e75cf5ba | ||
|
|
b2cba2aa2c |
3
.github/workflows/integrationci.yaml
vendored
3
.github/workflows/integrationci.yaml
vendored
@@ -39,7 +39,6 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- dashboard
|
||||
@@ -84,7 +83,7 @@ jobs:
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: webdriver
|
||||
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
|
||||
if: matrix.suite == 'callbackauthn'
|
||||
run: |
|
||||
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list
|
||||
|
||||
@@ -91,7 +91,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
sqlstoreProviderFactories(),
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
|
||||
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
},
|
||||
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
|
||||
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)
|
||||
|
||||
@@ -107,17 +107,17 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
sqlstoreProviderFactories(),
|
||||
signoz.NewTelemetryStoreProviderFactories(),
|
||||
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing, config.Global)
|
||||
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings, config.Global)
|
||||
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
|
||||
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -440,17 +440,6 @@ traces:
|
||||
max_depth_to_auto_expand: 5
|
||||
# Threshold below which all spans are returned without windowing.
|
||||
max_limit_to_select_all_spans: 10000
|
||||
flamegraph:
|
||||
# Maximum number of BFS depth levels included in a windowed response.
|
||||
max_selected_levels: 50
|
||||
# Maximum spans per level before sampling is applied.
|
||||
max_spans_per_level: 100
|
||||
# Number of highest-latency spans always included when sampling a level.
|
||||
sampling_top_latency_count: 5
|
||||
# Number of timestamp buckets used for uniform sampling within a level.
|
||||
sampling_bucket_count: 50
|
||||
# Threshold below which all spans are returned without windowing or sampling.
|
||||
select_all_spans_limit: 100000
|
||||
|
||||
##################### Authz #################################
|
||||
authz:
|
||||
|
||||
@@ -6638,70 +6638,6 @@ components:
|
||||
- attribute
|
||||
- resource
|
||||
type: string
|
||||
SpantypesFlamegraphSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
durationNano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
event:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
hasError:
|
||||
type: boolean
|
||||
level:
|
||||
format: int64
|
||||
type: integer
|
||||
name:
|
||||
type: string
|
||||
parentSpanId:
|
||||
type: string
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
spanId:
|
||||
type: string
|
||||
timestamp:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- spanId
|
||||
- parentSpanId
|
||||
- timestamp
|
||||
- durationNano
|
||||
- hasError
|
||||
- name
|
||||
- level
|
||||
- event
|
||||
- attributes
|
||||
- resource
|
||||
type: object
|
||||
SpantypesGettableFlamegraphTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
hasMore:
|
||||
type: boolean
|
||||
spans:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesFlamegraphSpan'
|
||||
type: array
|
||||
type: array
|
||||
startTimestampMillis:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- spans
|
||||
- startTimestampMillis
|
||||
- endTimestampMillis
|
||||
- hasMore
|
||||
type: object
|
||||
SpantypesGettableSpanMapperGroups:
|
||||
properties:
|
||||
items:
|
||||
@@ -6767,15 +6703,6 @@ components:
|
||||
traceId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableFlamegraph:
|
||||
properties:
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: array
|
||||
selectedSpanId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -20608,75 +20535,6 @@ paths:
|
||||
summary: Put profile in Zeus for a deployment.
|
||||
tags:
|
||||
- zeus
|
||||
/api/v3/traces/{traceID}/flamegraph:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Returns the flamegraph view of spans for a given trace ID.
|
||||
operationId: GetFlamegraph
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableFlamegraph'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableFlamegraphTrace'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get flamegraph view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v3/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -5,12 +5,10 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/client"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -28,14 +26,13 @@ var defaultScopes []string = []string{"email", "profile", oidc.ScopeOpenID}
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
httpClient *client.Client
|
||||
globalConfig global.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
httpClient *client.Client
|
||||
}
|
||||
|
||||
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
|
||||
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn")
|
||||
|
||||
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
|
||||
@@ -44,11 +41,10 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
|
||||
}
|
||||
|
||||
return &AuthN{
|
||||
settings: settings,
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
httpClient: httpClient,
|
||||
globalConfig: globalConfig,
|
||||
settings: settings,
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -201,7 +197,7 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
|
||||
RedirectURL: (&url.URL{
|
||||
Scheme: siteURL.Scheme,
|
||||
Host: siteURL.Host,
|
||||
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
|
||||
Path: redirectPath,
|
||||
}).String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -26,16 +24,14 @@ const (
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
globalConfig global.Config
|
||||
store authtypes.AuthNStore
|
||||
licensing licensing.Licensing
|
||||
}
|
||||
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (*AuthN, error) {
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) {
|
||||
return &AuthN{
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
globalConfig: globalConfig,
|
||||
store: store,
|
||||
licensing: licensing,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -136,7 +132,7 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
|
||||
return nil, err
|
||||
}
|
||||
|
||||
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: path.Join(a.globalConfig.ExternalPath(), redirectPath)}
|
||||
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath}
|
||||
|
||||
// Note:
|
||||
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.
|
||||
|
||||
@@ -7769,77 +7769,6 @@ export enum SpantypesFieldContextDTO {
|
||||
attribute = 'attribute',
|
||||
resource = 'resource',
|
||||
}
|
||||
export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
|
||||
|
||||
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
|
||||
|
||||
export interface SpantypesFlamegraphSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributes: SpantypesFlamegraphSpanDTOAttributes;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
durationNano: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
event: SpantypesEventDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasError: boolean;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
level: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parentSpanId: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
resource: SpantypesFlamegraphSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableFlamegraphTraceDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
endTimestampMillis: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMore: boolean;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
spans: SpantypesFlamegraphSpanDTO[][];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
startTimestampMillis: number;
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -8141,17 +8070,6 @@ export interface SpantypesGettableWaterfallTraceDTO {
|
||||
uncollapsedSpans?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableFlamegraphDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
selectedSpanId?: string;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOperationDTO {
|
||||
move = 'move',
|
||||
copy = 'copy',
|
||||
@@ -10506,17 +10424,6 @@ export type GetHosts200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetFlamegraph200 = {
|
||||
data: SpantypesGettableFlamegraphTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetFlamegraph200,
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetWaterfall200,
|
||||
@@ -21,7 +19,6 @@ import type {
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableFlamegraphDTO,
|
||||
SpantypesPostableTraceAggregationsDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -129,105 +126,6 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
*/
|
||||
export const getFlamegraph = (
|
||||
{ traceID }: GetFlamegraphPathParameters,
|
||||
spantypesPostableFlamegraphDTO?: BodyType<SpantypesPostableFlamegraphDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetFlamegraph200>({
|
||||
url: `/api/v3/traces/${traceID}/flamegraph`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableFlamegraphDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetFlamegraphMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getFlamegraph'];
|
||||
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 getFlamegraph>>,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getFlamegraph(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetFlamegraphMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>
|
||||
>;
|
||||
export type GetFlamegraphMutationBody =
|
||||
| BodyType<SpantypesPostableFlamegraphDTO>
|
||||
| undefined;
|
||||
export type GetFlamegraphMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get flamegraph view for a trace
|
||||
*/
|
||||
export const useGetFlamegraph = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getFlamegraph>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetFlamegraphPathParameters;
|
||||
data?: BodyType<SpantypesPostableFlamegraphDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetFlamegraphMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
|
||||
* @summary Get waterfall view for a trace
|
||||
|
||||
@@ -192,7 +192,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
@@ -52,14 +52,20 @@ function OtherFields({
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
(attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
key: buildCompositeKey(
|
||||
attr.name,
|
||||
attr.fieldContext as string,
|
||||
attr.fieldDataType as string | undefined,
|
||||
),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
}),
|
||||
);
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
addedFields.map(
|
||||
(f) => f.key ?? buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import type { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
type UseTracesTableColumnsProps<TRow> = {
|
||||
/** Pinned / always-on columns owned by the consumer (e.g. timestamp for List view, the 5 static columns for Traces grouped view). */
|
||||
baseColumns: TableColumnDef<TRow>[];
|
||||
/** Dynamic columns sourced from `selectColumns` (List view). Omit or pass [] for views without a picker (Traces grouped). */
|
||||
fields?: TelemetryFieldKey[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared column builder for the trace list view and the trace (group-by-trace) view.
|
||||
*
|
||||
* Composition: `[...baseColumns, ...fields.map(makeUserFieldCol)]`. Each view owns its
|
||||
* `baseColumns` inline so view-specific changes (timestamp formatting on list, static-column
|
||||
* cell renderers on grouped) stay localized. The shared piece is `makeUserFieldCol` — the
|
||||
* dynamic-field factory that consumes `selectColumns` for the list view.
|
||||
*/
|
||||
export function useTracesTableColumns<TRow>({
|
||||
baseColumns,
|
||||
fields = [],
|
||||
}: UseTracesTableColumnsProps<TRow>): TableColumnDef<TRow>[] {
|
||||
return useMemo<TableColumnDef<TRow>[]>(
|
||||
() => [...baseColumns, ...fields.map((f) => makeUserFieldCol<TRow>(f))],
|
||||
[baseColumns, fields],
|
||||
);
|
||||
}
|
||||
|
||||
function makeUserFieldCol<TRow>(f: TelemetryFieldKey): TableColumnDef<TRow> {
|
||||
const col: TableColumnDef<Record<string, unknown>> = {
|
||||
id: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
header: f.name,
|
||||
accessorFn: (row): unknown => row[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{stringifyCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
};
|
||||
return col as TableColumnDef<TRow>;
|
||||
}
|
||||
|
||||
function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -72,7 +72,7 @@ export const deploymentWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
title: 'Memory usage, request, limits)',
|
||||
yAxisUnit: 'bytes',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -69,7 +69,7 @@ export const jobWidgetInfo = [
|
||||
yAxisUnit: '',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage',
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -703,7 +703,7 @@ export const getNamespaceMetricsQueryPayload = (
|
||||
],
|
||||
having: [],
|
||||
legend: `{{${k8sPodNameKey}}}`,
|
||||
limit: 10,
|
||||
limit: 20,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
@@ -1014,8 +1014,8 @@ export const getNamespaceMetricsQueryPayload = (
|
||||
id: '5f2a55c5',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: k8sNamespaceNameKey,
|
||||
key: k8sNamespaceNameKey,
|
||||
id: k8sStatefulsetNameKey,
|
||||
key: k8sStatefulsetNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
|
||||
@@ -317,9 +317,9 @@ export const getVolumeMetricsQueryPayload = (
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'k8s_volume_inodes_used--float64--Gauge--true',
|
||||
id: 'k8s_volume_inodes_used--float64----true',
|
||||
key: k8sVolumeInodesUsedKey,
|
||||
type: 'Gauge',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
@@ -409,9 +409,9 @@ export const getVolumeMetricsQueryPayload = (
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'k8s_volume_inodes--float64--Gauge--true',
|
||||
id: 'k8s_volume_inodes--float64----true',
|
||||
key: k8sVolumeInodesKey,
|
||||
type: 'Gauge',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
@@ -501,9 +501,9 @@ export const getVolumeMetricsQueryPayload = (
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'k8s_volume_inodes_free--float64--Gauge--true',
|
||||
id: 'k8s_volume_inodes_free--float64----true',
|
||||
key: k8sVolumeInodesFreeKey,
|
||||
type: 'Gauge',
|
||||
type: '',
|
||||
},
|
||||
aggregateOperator: 'avg',
|
||||
dataSource: DataSource.METRICS,
|
||||
|
||||
@@ -1619,9 +1619,6 @@ export const getHostQueryPayload = (
|
||||
const diskOpTimeKey = dotMetricsEnabled
|
||||
? 'system.disk.operation_time'
|
||||
: 'system_disk_operation_time';
|
||||
const diskOpsKey = dotMetricsEnabled
|
||||
? 'system.disk.operations'
|
||||
: 'system_disk_operations';
|
||||
const diskPendingKey = dotMetricsEnabled
|
||||
? 'system.disk.pending_operations'
|
||||
: 'system_disk_pending_operations';
|
||||
@@ -2378,24 +2375,9 @@ export const getHostQueryPayload = (
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'direction--string--tag--false',
|
||||
|
||||
key: 'direction',
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'device--string--tag--false',
|
||||
|
||||
key: 'device',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
having: [],
|
||||
legend: '{{device}}::{{direction}}',
|
||||
legend: 'system disk io',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
@@ -2427,9 +2409,9 @@ export const getHostQueryPayload = (
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'system_disk_operations--float64--Sum--true',
|
||||
id: 'system_disk_operation_time--float64--Sum--true',
|
||||
|
||||
key: diskOpsKey,
|
||||
key: diskOpTimeKey,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'rate',
|
||||
@@ -2439,7 +2421,7 @@ export const getHostQueryPayload = (
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'diskops_f1',
|
||||
id: 'diskop_f1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'host_name--string--tag--false',
|
||||
@@ -2472,7 +2454,7 @@ export const getHostQueryPayload = (
|
||||
],
|
||||
having: [
|
||||
{
|
||||
columnName: `SUM(${diskOpsKey})`,
|
||||
columnName: `SUM(${diskOpTimeKey})`,
|
||||
op: '>',
|
||||
value: 0,
|
||||
},
|
||||
@@ -2575,88 +2557,6 @@ export const getHostQueryPayload = (
|
||||
start,
|
||||
end,
|
||||
},
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TIME_SERIES,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
aggregateAttribute: {
|
||||
dataType: DataTypes.Float64,
|
||||
id: 'system_disk_operation_time--float64--Sum--true',
|
||||
|
||||
key: diskOpTimeKey,
|
||||
type: 'Sum',
|
||||
},
|
||||
aggregateOperator: 'rate',
|
||||
dataSource: DataSource.METRICS,
|
||||
disabled: false,
|
||||
expression: 'A',
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
id: 'diskoptime_f1',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
id: 'host_name--string--tag--false',
|
||||
|
||||
key: hostNameKey,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: hostName,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'device--string--tag--false',
|
||||
|
||||
key: 'device',
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
id: 'direction--string--tag--false',
|
||||
|
||||
key: 'direction',
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
having: [
|
||||
{
|
||||
columnName: `SUM(${diskOpTimeKey})`,
|
||||
op: '>',
|
||||
value: 0,
|
||||
},
|
||||
],
|
||||
legend: '{{device}}::{{direction}}',
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
queryName: 'A',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
spaceAggregation: 'sum',
|
||||
stepInterval: 60,
|
||||
timeAggregation: 'rate',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
id: 'a8b3d2e1-4f5c-4a6b-9c8d-7e2f1a0b3c4f',
|
||||
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
formatForWeb: false,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -2731,5 +2631,5 @@ export const hostWidgetInfo = [
|
||||
{ title: 'System disk io (bytes transferred)', yAxisUnit: 'bytes' },
|
||||
{ title: 'System disk operations/s', yAxisUnit: 'short' },
|
||||
{ title: 'Queue size', yAxisUnit: 'short' },
|
||||
{ title: 'System disk operation time/s', yAxisUnit: 's' },
|
||||
{ title: 'Disk operations time', yAxisUnit: 's' },
|
||||
];
|
||||
|
||||
@@ -56,7 +56,7 @@ export function dedupeColumnsByCompositeKey(
|
||||
const seen = new Set<string>();
|
||||
let hasDuplicate = false;
|
||||
const deduped = columns.filter((c) => {
|
||||
const key = buildCompositeKey(c.name, c.fieldContext);
|
||||
const key = buildCompositeKey(c.name, c.fieldContext, c.fieldDataType);
|
||||
if (seen.has(key)) {
|
||||
hasDuplicate = true;
|
||||
return false;
|
||||
|
||||
@@ -281,7 +281,8 @@ const useOptionsMenu = ({
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
|
||||
(f) =>
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
@@ -364,7 +365,10 @@ const useOptionsMenu = ({
|
||||
(orderedIds: string[]): void => {
|
||||
const current = preferences?.columns ?? [];
|
||||
const byCompositeKey = new Map(
|
||||
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
|
||||
current.map((f) => [
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
f,
|
||||
]),
|
||||
);
|
||||
const reordered = orderedIds
|
||||
.map((id) => byCompositeKey.get(id))
|
||||
|
||||
@@ -15,8 +15,15 @@ export const getOptionsFromKeys = (
|
||||
);
|
||||
};
|
||||
|
||||
// Composite identity for a column. Disambiguates same-name fields across
|
||||
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
|
||||
// Falls back to bare name when context is missing.
|
||||
export const buildCompositeKey = (name: string, context?: string): string =>
|
||||
context ? `${context}.${name}` : name;
|
||||
// Composite column id. Disambiguates same-name fields by `context` and `dataType`
|
||||
// (e.g. attribute.http.status_code ships as both number and string). Each arg
|
||||
// is appended only when truthy. `dataType` is optional — logs callers stay on
|
||||
// the 2-arg form until parity lands.
|
||||
export const buildCompositeKey = (
|
||||
name: string,
|
||||
context?: string,
|
||||
dataType?: string,
|
||||
): string => {
|
||||
const withContext = context ? `${context}.${name}` : name;
|
||||
return dataType ? `${withContext}.${dataType}` : withContext;
|
||||
};
|
||||
|
||||
@@ -96,28 +96,14 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const {
|
||||
domainToAdminEmailList,
|
||||
allowedGroups,
|
||||
serviceAccountJson,
|
||||
domainToAdminEmail: _domainToAdminEmail,
|
||||
fetchTransitiveGroupMembership,
|
||||
...rest
|
||||
} = config;
|
||||
const { domainToAdminEmailList, ...rest } = config;
|
||||
const domainToAdminEmail = convertDomainMappingsToRecord(
|
||||
domainToAdminEmailList,
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(rest.fetchGroups
|
||||
? {
|
||||
allowedGroups,
|
||||
serviceAccountJson,
|
||||
domainToAdminEmail: domainToAdminEmail ?? {},
|
||||
fetchTransitiveGroupMembership,
|
||||
}
|
||||
: { domainToAdminEmail: {} }),
|
||||
domainToAdminEmail: domainToAdminEmail ?? {},
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
@@ -143,7 +129,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
|
||||
return {
|
||||
...rest,
|
||||
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
|
||||
groupMappings: groupMappings ?? {},
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
import {
|
||||
AUTH_DOMAINS_UPDATE_ENDPOINT,
|
||||
mockDomainWithRoleMapping,
|
||||
mockGoogleAuthDomain,
|
||||
mockGoogleAuthWithWorkspaceGroups,
|
||||
mockOidcWithClaimMapping,
|
||||
mockSamlWithAttributeMapping,
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
// @signozhq/ui/button internal effects block form.validateFields() in tests
|
||||
jest.mock('@signozhq/ui/button', () => ({
|
||||
...jest.requireActual('@signozhq/ui/button'),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
'aria-label': ariaLabel,
|
||||
prefix,
|
||||
suffix,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
prefix?: React.ReactNode;
|
||||
suffix?: React.ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{prefix}
|
||||
{children}
|
||||
{suffix}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
type SavedPayload = {
|
||||
config: {
|
||||
googleAuthConfig?: Record<string, unknown>;
|
||||
samlConfig?: Record<string, unknown>;
|
||||
oidcConfig?: Record<string, unknown>;
|
||||
roleMapping?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
async function submitForm(
|
||||
record: AuthtypesGettableAuthDomainDTO,
|
||||
): Promise<SavedPayload> {
|
||||
const requests: SavedPayload[] = [];
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
requests.push((await req.json()) as SavedPayload);
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<CreateEdit isCreate={false} record={record} onClose={jest.fn()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
await waitFor(() => expect(requests).toHaveLength(1));
|
||||
|
||||
return requests[0];
|
||||
}
|
||||
|
||||
describe('CreateEdit — payload sanitization', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
describe('Google Auth', () => {
|
||||
it('sends core fields and omits workspace fields when fetchGroups is not set', async () => {
|
||||
const payload = await submitForm(mockGoogleAuthDomain);
|
||||
|
||||
const g = payload.config.googleAuthConfig;
|
||||
expect(g?.clientId).toBe('test-client-id');
|
||||
expect(g?.clientSecret).toBe('test-client-secret');
|
||||
expect(g?.allowedGroups).toBeUndefined();
|
||||
expect(g?.serviceAccountJson).toBeUndefined();
|
||||
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
|
||||
expect(g?.domainToAdminEmail).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('strips workspace fields when fetchGroups is false', async () => {
|
||||
const payload = await submitForm({
|
||||
...mockGoogleAuthWithWorkspaceGroups,
|
||||
config: {
|
||||
...mockGoogleAuthWithWorkspaceGroups.config,
|
||||
googleAuthConfig: {
|
||||
...mockGoogleAuthWithWorkspaceGroups.config?.googleAuthConfig,
|
||||
fetchGroups: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const g = payload.config.googleAuthConfig;
|
||||
expect(g?.fetchGroups).toBe(false);
|
||||
expect(g?.allowedGroups).toBeUndefined();
|
||||
expect(g?.serviceAccountJson).toBeUndefined();
|
||||
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
|
||||
expect(g?.domainToAdminEmail).toStrictEqual({});
|
||||
});
|
||||
|
||||
it('includes all workspace fields when fetchGroups is true', async () => {
|
||||
const payload = await submitForm(mockGoogleAuthWithWorkspaceGroups);
|
||||
|
||||
const g = payload.config.googleAuthConfig;
|
||||
expect(g?.fetchGroups).toBe(true);
|
||||
expect(g?.serviceAccountJson).toBe('{"type": "service_account"}');
|
||||
expect(g?.fetchTransitiveGroupMembership).toBe(true);
|
||||
expect(g?.allowedGroups).toStrictEqual([
|
||||
'allowed-group-1',
|
||||
'allowed-group-2',
|
||||
]);
|
||||
expect(g?.domainToAdminEmail).toStrictEqual({
|
||||
'google-groups.com': 'admin@google-groups.com',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAML', () => {
|
||||
it('sends core and attributeMapping fields', async () => {
|
||||
const payload = await submitForm(mockSamlWithAttributeMapping);
|
||||
|
||||
const s = payload.config.samlConfig;
|
||||
expect(s?.samlIdp).toBe('https://idp.saml-attrs.com/sso');
|
||||
expect(s?.samlEntity).toBe('urn:saml-attrs:idp');
|
||||
expect(s?.samlCert).toBe('MOCK_CERTIFICATE_ATTRS');
|
||||
expect(s?.insecureSkipAuthNRequestsSigned).toBe(true);
|
||||
|
||||
const attr = s?.attributeMapping as Record<string, unknown>;
|
||||
expect(attr?.name).toBe('user_display_name');
|
||||
expect(attr?.groups).toBe('member_of');
|
||||
expect(attr?.role).toBe('signoz_role');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC', () => {
|
||||
it('sends all fields including claimMapping', async () => {
|
||||
const payload = await submitForm(mockOidcWithClaimMapping);
|
||||
|
||||
const o = payload.config.oidcConfig;
|
||||
expect(o?.issuer).toBe('https://oidc.claims.com');
|
||||
expect(o?.issuerAlias).toBe('https://alias.claims.com');
|
||||
expect(o?.clientId).toBe('claims-client-id');
|
||||
expect(o?.clientSecret).toBe('claims-client-secret');
|
||||
expect(o?.insecureSkipEmailVerified).toBe(true);
|
||||
expect(o?.getUserInfo).toBe(true);
|
||||
|
||||
const claim = o?.claimMapping as Record<string, unknown>;
|
||||
expect(claim?.email).toBe('user_email');
|
||||
expect(claim?.name).toBe('display_name');
|
||||
expect(claim?.groups).toBe('user_groups');
|
||||
expect(claim?.role).toBe('user_role');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Mapping', () => {
|
||||
it('strips groupMappings when useRoleAttribute is true', async () => {
|
||||
const payload = await submitForm({
|
||||
...mockDomainWithRoleMapping,
|
||||
config: {
|
||||
...mockDomainWithRoleMapping.config,
|
||||
roleMapping: {
|
||||
...mockDomainWithRoleMapping.config?.roleMapping,
|
||||
useRoleAttribute: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.config.roleMapping?.useRoleAttribute).toBe(true);
|
||||
expect(payload.config.roleMapping?.groupMappings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends groupMappings when useRoleAttribute is false', async () => {
|
||||
const payload = await submitForm(mockDomainWithRoleMapping);
|
||||
|
||||
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
|
||||
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,62 +2,37 @@ import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function TraceExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
if (!config?.fieldsSelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -67,16 +42,8 @@ TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
type TraceExplorerControlsProps = {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
@@ -54,18 +54,17 @@ const renderListView = (
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to verify all controls are visible
|
||||
// Helper to verify all controls are visible.
|
||||
// Pagination controls were removed in the TanStack-table migration (infinite
|
||||
// scroll replaces page-by-page navigation), so only the order-by combobox +
|
||||
// options trigger remain in the top toolbar.
|
||||
const verifyControlsVisibility = (): void => {
|
||||
// Order by controls
|
||||
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
|
||||
|
||||
// Pagination controls
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
|
||||
// Items per page selector (there are multiple comboboxes, so we check for at least 2)
|
||||
// At least one combobox (order-by); page-size selector is gone post-migration.
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu (settings button) - check for translation key or actual text
|
||||
expect(screen.getByText(/options_menu.options|options/i)).toBeInTheDocument();
|
||||
@@ -152,15 +151,10 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
expect(screen.getByText(/Order by/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Order by controls should be interactive
|
||||
// Order-by combobox should be interactive (pagination buttons removed
|
||||
// after the TanStack migration switched List view to infinite scroll).
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Pagination controls should be present
|
||||
const previousButton = screen.getByRole('button', { name: /previous/i });
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(previousButton).toBeInTheDocument();
|
||||
expect(nextButton).toBeInTheDocument();
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
@@ -175,9 +169,9 @@ describe('Traces ListView - Error and Empty States', () => {
|
||||
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// All controls should be interactive
|
||||
// At least the order-by combobox should be interactive.
|
||||
const comboboxes = screen.getAllByRole('combobox');
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(2);
|
||||
expect(comboboxes.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Options menu should be clickable
|
||||
const optionsButton = screen.getByText(/options_menu.options|options/i);
|
||||
|
||||
@@ -10,14 +10,16 @@ import {
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useTracesTableColumns } from 'components/Traces/TableView/useTracesTableColumns';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
@@ -28,24 +30,28 @@ import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { Container, tableStyles } from './styles';
|
||||
import { getListColumns, transformDataWithDate } from './utils';
|
||||
import { Container } from './styles';
|
||||
import {
|
||||
getTraceLink,
|
||||
makeListFieldCol,
|
||||
makeTimestampCol,
|
||||
SpanRow,
|
||||
transformSpanRows,
|
||||
} from './utils';
|
||||
|
||||
import './ListView.styles.scss';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
@@ -59,6 +65,7 @@ function ListView({
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
@@ -77,25 +84,22 @@ function ListView({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
// Infinite-scroll state — owned by this view.
|
||||
const [pagination, setPagination] = useState<{
|
||||
offset: number;
|
||||
limit: number;
|
||||
}>({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
const [accumulatedRows, setAccumulatedRows] = useState<SpanRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// TEMP — remove after traces moves to TanStack table.
|
||||
// Stable sorted-name signature for the queryKey + reset trigger.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch from scratch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
@@ -106,6 +110,25 @@ function ListView({
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
// Reset accumulator + offset whenever the underlying query identity changes.
|
||||
useEffect(() => {
|
||||
setPagination({ offset: 0, limit: PAGE_SIZE });
|
||||
setAccumulatedRows([]);
|
||||
setHasMore(true);
|
||||
}, [
|
||||
stagedQuery?.id,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
selectColumnsSignature,
|
||||
]);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
@@ -114,18 +137,18 @@ function ListView({
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
pagination,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
pagination,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
@@ -144,16 +167,14 @@ function ListView({
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
pagination,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
@@ -168,6 +189,19 @@ function ListView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
// Append fetched page to accumulator (replace when offset === 0).
|
||||
const responseResult = data?.payload?.data?.newResult?.data?.result;
|
||||
useEffect(() => {
|
||||
if (!responseResult) {
|
||||
return;
|
||||
}
|
||||
const newRows = transformSpanRows(responseResult);
|
||||
setAccumulatedRows((prev) =>
|
||||
pagination.offset === 0 ? newRows : [...prev, ...newRows],
|
||||
);
|
||||
setHasMore(newRows.length >= pagination.limit);
|
||||
}, [responseResult, pagination.offset, pagination.limit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
@@ -176,68 +210,50 @@ function ListView({
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const dataLength =
|
||||
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
|
||||
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && accumulatedRows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', { panelType });
|
||||
}
|
||||
}, [isLoading, isFetching, isError, accumulatedRows.length, panelType]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getListColumns(
|
||||
options?.selectColumns || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
),
|
||||
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
const transformedQueryTableData = useMemo(
|
||||
() => transformDataWithDate(queryTableData) || [],
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleDragColumn = useCallback(
|
||||
(fromIndex: number, toIndex: number): void => {
|
||||
const reordered = [...columns];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
// `key` is the composite (fieldContext.name) — disambiguates same-name fields.
|
||||
const orderedIds = reordered
|
||||
.map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || ''))
|
||||
.filter(Boolean);
|
||||
config?.addColumn?.onReorder(orderedIds);
|
||||
},
|
||||
[columns, config],
|
||||
);
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (!hasMore) {
|
||||
return;
|
||||
}
|
||||
setPagination((p) => ({ ...p, offset: p.offset + p.limit }));
|
||||
}, [hasMore]);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length === 0;
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const baseColumns = useMemo(
|
||||
() => [
|
||||
makeTimestampCol(formatTimezoneAdjustedTimestamp),
|
||||
...(options?.selectColumns ?? []).map(makeListFieldCol),
|
||||
],
|
||||
[formatTimezoneAdjustedTimestamp, options?.selectColumns],
|
||||
);
|
||||
|
||||
const tableColumns = useTracesTableColumns<SpanRow>({ baseColumns });
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: SpanRow): void => {
|
||||
history.push(getTraceLink(row));
|
||||
},
|
||||
[history],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback((row: SpanRow): void => {
|
||||
window.open(
|
||||
getAbsoluteUrl(getTraceLink(row)),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length !== 0
|
||||
) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
|
||||
return (
|
||||
<Container>
|
||||
<div className="trace-explorer-controls">
|
||||
@@ -258,39 +274,54 @@ function ListView({
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={totalCount}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
<TraceExplorerControls config={config} />
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
|
||||
{(isLoading || isFetching) && accumulatedRows.length === 0 && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
accumulatedRows.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
accumulatedRows.length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && transformedQueryTableData.length !== 0 && (
|
||||
<ResizeTable
|
||||
tableLayout="fixed"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isFetching}
|
||||
style={tableStyles}
|
||||
dataSource={transformedQueryTableData}
|
||||
columns={columns}
|
||||
onDragColumn={handleDragColumn}
|
||||
/>
|
||||
{accumulatedRows.length !== 0 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<TanStackTable<SpanRow>
|
||||
data={accumulatedRows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
|
||||
respectColumnOrder={false}
|
||||
cellTypographySize="medium"
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={handleEndReached}
|
||||
onColumnOrderChange={(cols): void =>
|
||||
config?.addColumn?.onReorder(cols.map((c) => c.id))
|
||||
}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
// Kept for legacy antd consumers (TracesTableComponent, LogsPanelComponent).
|
||||
// The TanStack ListView doesn't use it.
|
||||
export const tableStyles: CSSProperties = {
|
||||
cursor: 'unset',
|
||||
};
|
||||
@@ -9,13 +10,30 @@ export const tableStyles: CSSProperties = {
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Fallback: the page-level CSS chain (.trace-explorer-page → .trace-explorer →
|
||||
// .traces-explorer-views) isn't a flex column today, so flex:1 alone has nothing
|
||||
// to flex against. Anchor a height via the viewport so react-virtuoso (inside
|
||||
// TanStackTable) has a sized parent to render into.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
|
||||
// Match logs explorer table typography (mirrors LogsExplorerList.style.scss).
|
||||
font-family: 'Space Mono', monospace;
|
||||
|
||||
// Row hover affordance — TanStack's row hover reads var(--row-hover-bg) with no
|
||||
// fallback, so without setting it hover is invisible.
|
||||
--row-hover-bg: var(--l1-border);
|
||||
|
||||
// Small leading gap before the pinned timestamp column. No drag handle here
|
||||
// (pinned columns aren't movable), so we don't need the full 12px we use in
|
||||
// the grouped Traces view — 5px just keeps the text off the table edge.
|
||||
--tanstack-cell-padding-left-first-column: 5px;
|
||||
|
||||
// Allow dynamic-field cells to clamp to 3 lines (matches old LineClampedText
|
||||
// behavior). Header + intrinsic columns stay 1-line by their own settings.
|
||||
--tanstack-plain-body-line-clamp: 3;
|
||||
|
||||
--typography-color: var(--l1-foreground);
|
||||
`;
|
||||
|
||||
export const ErrorText = styled(Typography)`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
export const DateText = styled(Typography)`
|
||||
min-width: 145px;
|
||||
`;
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
@@ -14,6 +16,14 @@ import LineClampedText from 'periscope/components/LineClampedText/LineClampedTex
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
// `BlockLink`, `getListColumns`, `transformDataWithDate` are kept for legacy
|
||||
// antd consumers. `getTraceLink` is shared with the TanStack ListView, which
|
||||
// otherwise uses `make*Col` / `SpanRow` / `transformSpanRows`.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy antd consumers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BlockLink({
|
||||
children,
|
||||
to,
|
||||
@@ -41,12 +51,43 @@ export const transformDataWithDate = (
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: RowData): string =>
|
||||
`${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({
|
||||
spanId: record.spanID || record.span_id,
|
||||
/**
|
||||
* Reads camelCase OR snake_case at runtime — both legacy `RowData` and the new
|
||||
* `SpanRow` (each used by different ListView/utils consumers) satisfy
|
||||
* `Record<string, unknown>` because their named props are subtypes of `unknown`.
|
||||
*/
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
const traceId = readId(record.traceID) || readId(record.trace_id);
|
||||
const spanId = readId(record.spanID) || readId(record.span_id);
|
||||
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
@@ -136,3 +177,107 @@ export const getListColumns = (
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TanStack ListView (current)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Span row shape for the trace list view. Known intrinsic fields explicit; the
|
||||
// rest of the row comes from user-selected dynamic columns (selectColumns), hence
|
||||
// the Record intersection. `timestamp` is added by transformSpanRows from the
|
||||
// API's wrapping ListItem.timestamp (data itself omits it).
|
||||
export type SpanRow = {
|
||||
trace_id: string;
|
||||
span_id: string;
|
||||
timestamp: string;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export const transformSpanRows = (data: QueryDataV3[]): SpanRow[] => {
|
||||
const list = data[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => ({
|
||||
...(item.data as Record<string, unknown>),
|
||||
timestamp: item.timestamp,
|
||||
})) as unknown as SpanRow[];
|
||||
};
|
||||
|
||||
// Field-name allowlists that drive signal-specific cell rendering (kept from the
|
||||
// pre-TanStack getListColumns). Both legacy camelCase + snake_case variants are
|
||||
// listed because the API has shipped both over time.
|
||||
const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
]);
|
||||
const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
type TimestampFormatter = (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number;
|
||||
|
||||
export function makeTimestampCol(
|
||||
formatTimezoneAdjustedTimestamp: TimestampFormatter,
|
||||
): TableColumnDef<SpanRow> {
|
||||
return {
|
||||
id: buildCompositeKey('timestamp', 'span'),
|
||||
header: 'Timestamp',
|
||||
accessorFn: (row): unknown => row.timestamp,
|
||||
// Pinned left as a visual anchor during horizontal scroll. Trade-off: the
|
||||
// sticky-positioning + cell `overflow: hidden` in TanStackTable.module.scss
|
||||
// makes the right-edge resize handle effectively unhittable for pinned
|
||||
// columns — accepted.
|
||||
pin: 'left',
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): JSX.Element => {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{String(formatted)}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeListFieldCol(
|
||||
f: TelemetryFieldKey,
|
||||
): TableColumnDef<SpanRow> {
|
||||
return {
|
||||
id: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
header: f.name,
|
||||
accessorFn: (row): unknown => row[f.name],
|
||||
enableRemove: true,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => {
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={f.name}>N/A</TanStackTable.Text>;
|
||||
}
|
||||
const text = stringifyCellValue(value);
|
||||
if (STATUS_FIELD_NAMES.has(f.name)) {
|
||||
return (
|
||||
<Badge data-testid={f.name} color="sakura" variant="outline">
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (DURATION_FIELD_NAMES.has(f.name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={f.name}>
|
||||
{getMs(text)}
|
||||
ms
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
return <TanStackTable.Text data-testid={f.name}>{text}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,50 +1,69 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
import { ListItem } from 'types/api/widgets/getQuery';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
export const columns: ColumnsType<ListItem['data']> = [
|
||||
// Trace-grouped (group-by-trace) row shape. Distinct from logs' `ListItem.data`
|
||||
// (which is `Omit<ILog, 'timestamp' | 'span_id'>` — the legacy logs shape).
|
||||
// Trace rows ship trace-summary fields; runtime keys often contain dots (e.g.
|
||||
// `service.name`), so the row indexes via string keys, not nested-property access.
|
||||
export type TraceRow = {
|
||||
'service.name': string;
|
||||
name: string;
|
||||
duration_nano: number | string;
|
||||
span_count: number | string;
|
||||
trace_id: string;
|
||||
};
|
||||
|
||||
export const columns: TableColumnDef<TraceRow>[] = [
|
||||
{
|
||||
title: 'Root Service Name',
|
||||
dataIndex: 'service.name',
|
||||
key: 'serviceName',
|
||||
},
|
||||
{
|
||||
title: 'Root Operation Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Root Duration (in ms)',
|
||||
dataIndex: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
render: (duration: number): JSX.Element => (
|
||||
<Typography>{getMs(String(duration))}ms</Typography>
|
||||
id: buildCompositeKey('service.name', 'resource'),
|
||||
header: 'Root Service Name',
|
||||
accessorFn: (row): unknown => row['service.name'],
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 192 },
|
||||
},
|
||||
{
|
||||
title: 'No of Spans',
|
||||
dataIndex: 'span_count',
|
||||
key: 'span_count',
|
||||
},
|
||||
{
|
||||
title: 'TraceID',
|
||||
dataIndex: 'trace_id',
|
||||
key: 'traceID',
|
||||
render: (traceID: string): JSX.Element => (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceID,
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
id: 'name',
|
||||
header: 'Root Operation Name',
|
||||
accessorFn: (row): unknown => row.name,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text data-testid="trace-id">
|
||||
{String(value ?? '')}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 200 },
|
||||
},
|
||||
{
|
||||
id: 'duration_nano',
|
||||
header: 'Root Duration (in ms)',
|
||||
accessorFn: (row): unknown => row.duration_nano,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{getMs(String(value))}ms</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 130 },
|
||||
},
|
||||
{
|
||||
id: 'span_count',
|
||||
header: 'No of Spans',
|
||||
accessorFn: (row): unknown => row.span_count,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 100 },
|
||||
},
|
||||
{
|
||||
id: 'trace_id',
|
||||
header: 'TraceID',
|
||||
accessorFn: (row): unknown => row.trace_id,
|
||||
cell: ({ value }): JSX.Element => (
|
||||
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
|
||||
),
|
||||
width: { min: 250 },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,38 +4,44 @@ import {
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useHistory } from 'react-router-dom';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useTracesTableColumns } from 'components/Traces/TableView/useTracesTableColumns';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { columns as baseColumns, TraceRow } from './configs';
|
||||
import { ActionsContainer, Container } from './styles';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
@@ -49,6 +55,7 @@ function TracesView({
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
@@ -57,9 +64,20 @@ function TracesView({
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
// Infinite-scroll state — owned by this view.
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
const [accumulatedRows, setAccumulatedRows] = useState<TraceRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
// Reset accumulator + offset whenever the underlying query identity changes.
|
||||
useEffect(() => {
|
||||
setPagination({ offset: 0, limit: PAGE_SIZE });
|
||||
setAccumulatedRows([]);
|
||||
setHasMore(true);
|
||||
}, [stagedQuery?.id, globalSelectedTime, maxTime, minTime]);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
@@ -74,16 +92,9 @@ function TracesView({
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
pagination,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery, panelType, pagination],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
@@ -100,7 +111,7 @@ function TracesView({
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
pagination,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -117,11 +128,20 @@ function TracesView({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
const tableData = useMemo(
|
||||
() => responseData?.map((listItem) => listItem.data),
|
||||
[responseData],
|
||||
);
|
||||
// Append fetched page to accumulator (replace when offset === 0).
|
||||
const responseList = data?.payload?.data?.newResult?.data?.result?.[0]?.list;
|
||||
useEffect(() => {
|
||||
if (!responseList) {
|
||||
return;
|
||||
}
|
||||
// API returns trace-summary rows; the `ListItem.data` static type is the
|
||||
// legacy logs shape, so route through `unknown` to land on `TraceRow`.
|
||||
const newRows = responseList.map((li) => li.data) as unknown as TraceRow[];
|
||||
setAccumulatedRows((prev) =>
|
||||
pagination.offset === 0 ? newRows : [...prev, ...newRows],
|
||||
);
|
||||
setHasMore(newRows.length >= pagination.limit);
|
||||
}, [responseList, pagination.offset, pagination.limit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
@@ -132,16 +152,48 @@ function TracesView({
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
if (!isLoading && !isFetching && !isError && accumulatedRows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, panelType, tableData]);
|
||||
}, [isLoading, isFetching, isError, accumulatedRows.length]);
|
||||
|
||||
const handleEndReached = useCallback(() => {
|
||||
if (!hasMore) {
|
||||
return;
|
||||
}
|
||||
setPagination((p) => ({ ...p, offset: p.offset + p.limit }));
|
||||
}, [hasMore]);
|
||||
|
||||
const tableColumns = useTracesTableColumns<TraceRow>({ baseColumns });
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TraceRow): void => {
|
||||
const traceId = String(row.trace_id);
|
||||
history.push(generatePath(ROUTES.TRACE_DETAIL, { id: traceId }));
|
||||
},
|
||||
[history],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback((row: TraceRow): void => {
|
||||
const traceId = String(row.trace_id);
|
||||
const path = generatePath(ROUTES.TRACE_DETAIL, { id: traceId });
|
||||
window.open(getAbsoluteUrl(path), '_blank', 'noopener,noreferrer');
|
||||
}, []);
|
||||
|
||||
//oxlint-disable-next-line no-console
|
||||
console.log('TracesView rendered with rows:', {
|
||||
accumulatedRows,
|
||||
tableColumns,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
});
|
||||
return (
|
||||
<Container>
|
||||
{(tableData || []).length !== 0 && (
|
||||
{accumulatedRows.length !== 0 && (
|
||||
<ActionsContainer>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
@@ -150,20 +202,12 @@ function TracesView({
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</ActionsContainer>
|
||||
)}
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
|
||||
{(isLoading || isFetching) && accumulatedRows.length === 0 && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
@@ -171,25 +215,36 @@ function TracesView({
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
accumulatedRows.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
(tableData || []).length === 0 &&
|
||||
accumulatedRows.length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
|
||||
)}
|
||||
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ResizeTable
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
scroll={{ x: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
{accumulatedRows.length !== 0 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<TanStackTable<TraceRow>
|
||||
data={accumulatedRows}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
|
||||
cellTypographySize="medium"
|
||||
isLoading={isLoading || isFetching}
|
||||
onEndReached={handleEndReached}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,16 @@ import styled from 'styled-components';
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
|
||||
// Row hover affordance
|
||||
--row-hover-bg: var(--l1-border);
|
||||
|
||||
// Breathing room before the first column so cell content doesn't hug the corner.
|
||||
--tanstack-cell-padding-left-first-column: 12px;
|
||||
`;
|
||||
|
||||
export const ActionsContainer = styled.div`
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import ListView from 'container/TracesExplorer/ListView';
|
||||
import { defaultSelectedColumns } from 'container/TracesExplorer/ListView/configs';
|
||||
import QuerySection from 'container/TracesExplorer/QuerySection';
|
||||
import TableView from 'container/TracesExplorer/TableView';
|
||||
import TracesView from 'container/TracesExplorer/TracesView';
|
||||
@@ -80,9 +79,6 @@ function TracesExplorer(): JSX.Element {
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useEffect, useState } from 'react';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { defaultSelectedColumns as defaultTracesSelectedColumns } from 'container/TracesExplorer/ListView/configs';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -69,7 +69,7 @@ export function usePreferenceSync({
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
columns = parsedExtraData?.selectColumns || defaultTracesSelectedColumns;
|
||||
columns = parsedExtraData?.selectColumns || defaultTraceSelectedColumns;
|
||||
}
|
||||
setSavedViewPreferences({ columns, formatting });
|
||||
}, [viewsData, dataSource, savedViewId, mode]);
|
||||
|
||||
@@ -67,24 +67,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v3/traces/{traceID}/flamegraph", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetFlamegraph),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetFlamegraph",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get flamegraph view for a trace",
|
||||
Description: "Returns the flamegraph view of spans for a given trace ID.",
|
||||
Request: new(spantypes.PostableFlamegraph),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableFlamegraphTrace),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/authn"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/client"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -31,13 +29,12 @@ var scopes []string = []string{"email", "profile"}
|
||||
var _ authn.CallbackAuthN = (*AuthN)(nil)
|
||||
|
||||
type AuthN struct {
|
||||
store authtypes.AuthNStore
|
||||
settings factory.ScopedProviderSettings
|
||||
httpClient *client.Client
|
||||
globalConfig global.Config
|
||||
store authtypes.AuthNStore
|
||||
settings factory.ScopedProviderSettings
|
||||
httpClient *client.Client
|
||||
}
|
||||
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
|
||||
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings) (*AuthN, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn")
|
||||
|
||||
httpClient, err := client.New(settings.Logger(), providerSettings.TracerProvider, providerSettings.MeterProvider)
|
||||
@@ -46,10 +43,9 @@ func New(ctx context.Context, store authtypes.AuthNStore, providerSettings facto
|
||||
}
|
||||
|
||||
return &AuthN{
|
||||
store: store,
|
||||
settings: settings,
|
||||
httpClient: httpClient,
|
||||
globalConfig: globalConfig,
|
||||
store: store,
|
||||
settings: settings,
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -182,7 +178,7 @@ func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain,
|
||||
RedirectURL: (&url.URL{
|
||||
Scheme: siteURL.Scheme,
|
||||
Host: siteURL.Host,
|
||||
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
|
||||
Path: redirectPath,
|
||||
}).String(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
@@ -17,12 +15,11 @@ import (
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module session.Module
|
||||
globalConfig global.Config
|
||||
module session.Module
|
||||
}
|
||||
|
||||
func NewHandler(module session.Module, globalConfig global.Config) session.Handler {
|
||||
return &handler{module: module, globalConfig: globalConfig}
|
||||
func NewHandler(module session.Module) session.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) GetSessionContext(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -161,13 +158,13 @@ func (handler *handler) DeleteSession(rw http.ResponseWriter, req *http.Request)
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) getRedirectURLFromErr(err error) string {
|
||||
func (*handler) getRedirectURLFromErr(err error) string {
|
||||
values := errors.AsURLValues(err)
|
||||
values.Add("callbackauthnerr", "true")
|
||||
|
||||
return (&url.URL{
|
||||
// When UI is being served on a prefix, we need to redirect to the login page on the prefix.
|
||||
Path: path.Join(handler.globalConfig.ExternalPath(), "/login"),
|
||||
Path: "/login",
|
||||
RawQuery: values.Encode(),
|
||||
}).String()
|
||||
}
|
||||
|
||||
@@ -6,16 +6,7 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Waterfall WaterfallConfig `mapstructure:"waterfall"`
|
||||
Flamegraph FlamegraphConfig `mapstructure:"flamegraph"`
|
||||
}
|
||||
|
||||
type FlamegraphConfig struct {
|
||||
MaxSelectedLevels int `mapstructure:"max_selected_levels"`
|
||||
MaxSpansPerLevel int `mapstructure:"max_spans_per_level"`
|
||||
SamplingTopLatencySpansCount int `mapstructure:"sampling_top_latency_count"`
|
||||
SamplingBucketCount int `mapstructure:"sampling_bucket_count"`
|
||||
SelectAllSpansLimit uint `mapstructure:"select_all_spans_limit"`
|
||||
Waterfall WaterfallConfig `mapstructure:"waterfall"`
|
||||
}
|
||||
|
||||
type WaterfallConfig struct {
|
||||
@@ -38,13 +29,6 @@ func newConfig() factory.Config {
|
||||
MaxDepthToAutoExpand: 5,
|
||||
MaxLimitToSelectAllSpans: 10_000,
|
||||
},
|
||||
Flamegraph: FlamegraphConfig{
|
||||
MaxSelectedLevels: 50,
|
||||
MaxSpansPerLevel: 100,
|
||||
SamplingTopLatencySpansCount: 5,
|
||||
SamplingBucketCount: 50,
|
||||
SelectAllSpansLimit: 100_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,20 +42,5 @@ func (c Config) Validate() error {
|
||||
if c.Waterfall.MaxLimitToSelectAllSpans == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "traces.waterfall.max_limit_to_select_all_spans must be positive")
|
||||
}
|
||||
if c.Flamegraph.MaxSelectedLevels <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.level_limit must be positive, got %d", c.Flamegraph.MaxSelectedLevels)
|
||||
}
|
||||
if c.Flamegraph.MaxSpansPerLevel <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.spans_per_level must be positive, got %d", c.Flamegraph.MaxSpansPerLevel)
|
||||
}
|
||||
if c.Flamegraph.SamplingTopLatencySpansCount < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.top_latency_count cannot be negative, got %d", c.Flamegraph.SamplingTopLatencySpansCount)
|
||||
}
|
||||
if c.Flamegraph.SamplingBucketCount <= 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.bucket_count must be positive, got %d", c.Flamegraph.SamplingBucketCount)
|
||||
}
|
||||
if c.Flamegraph.SelectAllSpansLimit == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tracedetail.flamegraph.max_limit_to_select_all_spans must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -80,19 +80,3 @@ func (h *handler) GetTraceAggregations(rw http.ResponseWriter, r *http.Request)
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableFlamegraph)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetFlamegraph(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.SelectFields)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
@@ -165,17 +164,6 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
|
||||
return &spantypes.GettableTraceAggregations{Aggregations: results}, nil
|
||||
}
|
||||
|
||||
func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
|
||||
return m.getFullFlamegraph(ctx, traceID, summary, selectFields)
|
||||
}
|
||||
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, summary, selectFields)
|
||||
}
|
||||
|
||||
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
|
||||
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
|
||||
// Step 1: minimal fetch → build full tree → select visible window
|
||||
@@ -216,47 +204,3 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
|
||||
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
|
||||
), nil
|
||||
}
|
||||
|
||||
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fullSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
|
||||
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start.UnixMilli(), summary.End.UnixMilli(), false), nil
|
||||
}
|
||||
|
||||
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
|
||||
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(minimalSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
|
||||
minimalSpans = nil //nolint:ineffassign,wastedassign // release backing array before further db calls
|
||||
|
||||
cfg := m.config.Flamegraph
|
||||
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID, cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
|
||||
if len(selectedSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return spantypes.NewGettableFlamegraphTrace(
|
||||
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields),
|
||||
summary.Start.UnixMilli(),
|
||||
summary.End.UnixMilli(),
|
||||
true,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -154,47 +154,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"span_id",
|
||||
"any(parent_span_id) AS parent_span_id",
|
||||
"any(timestamp) AS timestamp",
|
||||
"any(duration_nano) AS duration_nano",
|
||||
"any(has_error) AS has_error",
|
||||
"any(name) AS name",
|
||||
"any(events) AS events",
|
||||
"any(attributes_string) AS attributes_string",
|
||||
"any(attributes_number) AS attributes_number",
|
||||
"any(attributes_bool) AS attributes_bool",
|
||||
"any(resources_string) AS resources_string",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
conditions := []string{
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", end.Unix()),
|
||||
}
|
||||
if len(spanIDs) > 0 {
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
ids[i] = id
|
||||
}
|
||||
conditions = append(conditions, sb.In("span_id", ids...))
|
||||
}
|
||||
sb.Where(conditions...)
|
||||
sb.GroupBy("span_id")
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("name")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying flamegraph spans")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
fieldExpr, err := buildFieldExpr(fieldKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -91,30 +91,6 @@ func TestGetSpanCountByField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFlamegraphSpans(t *testing.T) {
|
||||
baseSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY span_id ORDER BY timestamp ASC, name ASC"
|
||||
withSpanIDsSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND span_id IN (?, ?) GROUP BY span_id ORDER BY timestamp ASC, name ASC"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spanIDs []string
|
||||
sql string
|
||||
}{
|
||||
{name: "NoSpanIDs_GeneratesBaseSQL", spanIDs: nil, sql: baseSQL},
|
||||
{name: "WithSpanIDs_GeneratesInClauseSQL", spanIDs: []string{"span-1", "span-2"}, sql: withSpanIDsSQL},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := newTestStore(sqlmock.QueryMatcherRegexp)
|
||||
s.Mock().ExpectSelect(regexp.QuoteMeta(tc.sql)).
|
||||
WillReturnRows(cmock.NewRows(nil, nil))
|
||||
_, _ = s.Store().GetFlamegraphSpans(context.Background(), testTraceID, testStart, testEnd, tc.spanIDs)
|
||||
assert.NoError(t, s.Mock().ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSpanDurationByField(t *testing.T) {
|
||||
|
||||
expectedSQL := "WITH all_spans AS (SELECT DISTINCT ON (span_id) resource.`service.name`::String AS field_value, toUnixTimestamp64Nano(timestamp) AS start_ns, start_ns + duration_nano AS end_ns FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND notEmpty(field_value) ORDER BY timestamp ASC, name ASC), effective_start AS (SELECT field_value, end_ns, greatest(start_ns, ifNull(max(end_ns) OVER (PARTITION BY field_value ORDER BY start_ns ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), toUInt64(0))) AS effective_start_ns FROM all_spans) SELECT field_value, sum(toUInt64(greatest(end_ns - effective_start_ns, 0))) AS total_ns FROM effective_start GROUP BY field_value"
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
@@ -13,7 +12,6 @@ type Handler interface {
|
||||
GetWaterfall(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
GetTraceAggregations(http.ResponseWriter, *http.Request)
|
||||
GetFlamegraph(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
@@ -21,5 +19,4 @@ type Module interface {
|
||||
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
|
||||
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
|
||||
}
|
||||
|
||||
@@ -7,15 +7,14 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn"
|
||||
"github.com/SigNoz/signoz/pkg/authn/passwordauthn/emailpasswordauthn"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
|
||||
emailPasswordAuthN := emailpasswordauthn.New(store)
|
||||
|
||||
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings, globalConfig)
|
||||
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -275,14 +275,14 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
|
||||
)
|
||||
}
|
||||
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozapiserver.NewFactory(
|
||||
orgGetter,
|
||||
authz,
|
||||
implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
|
||||
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
|
||||
implsession.NewHandler(modules.Session, globalConfig),
|
||||
implsession.NewHandler(modules.Session),
|
||||
implauthdomain.NewHandler(modules.AuthDomain),
|
||||
implpreference.NewHandler(modules.Preference),
|
||||
handlers.Global,
|
||||
|
||||
@@ -95,7 +95,6 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
nil,
|
||||
Modules{},
|
||||
Handlers{},
|
||||
global.Config{},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -542,7 +542,7 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.APIServer,
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global),
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers),
|
||||
"signoz",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type FlamegraphSpan struct {
|
||||
SpanID string `json:"spanId" required:"true"`
|
||||
ParentSpanID string `json:"parentSpanId" required:"true"`
|
||||
Timestamp uint64 `json:"timestamp" required:"true"`
|
||||
DurationNano uint64 `json:"durationNano" required:"true"`
|
||||
HasError bool `json:"hasError" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Level int64 `json:"level" required:"true"`
|
||||
Events []Event `json:"event" required:"true" nullable:"false"`
|
||||
Attributes map[string]any `json:"attributes" required:"true" nullable:"false"`
|
||||
Resource map[string]string `json:"resource" required:"true" nullable:"false"`
|
||||
Children []*FlamegraphSpan `json:"-"` // internal tree use only
|
||||
}
|
||||
|
||||
// FlamegraphLevel groups span IDs at a single level within the selected window.
|
||||
type FlamegraphLevel struct {
|
||||
Level int64
|
||||
SpanIDs []string
|
||||
}
|
||||
|
||||
type PostableFlamegraph struct {
|
||||
SelectedSpanID string `json:"selectedSpanId"`
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
|
||||
}
|
||||
|
||||
// GettableFlamegraphTrace is the response for the v3 flamegraph API.
|
||||
type GettableFlamegraphTrace struct {
|
||||
Spans [][]*FlamegraphSpan `json:"spans" required:"true" nullable:"false"`
|
||||
StartTimestampMillis int64 `json:"startTimestampMillis" required:"true"`
|
||||
EndTimestampMillis int64 `json:"endTimestampMillis" required:"true"`
|
||||
HasMore bool `json:"hasMore" required:"true"`
|
||||
}
|
||||
|
||||
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, startMs, endMs int64, hasMore bool) *GettableFlamegraphTrace {
|
||||
return &GettableFlamegraphTrace{
|
||||
Spans: spans,
|
||||
StartTimestampMillis: startMs,
|
||||
EndTimestampMillis: endMs,
|
||||
HasMore: hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64, selectFields []telemetrytypes.TelemetryFieldKey) *FlamegraphSpan {
|
||||
span := &FlamegraphSpan{
|
||||
SpanID: s.SpanID,
|
||||
ParentSpanID: s.ParentSpanID,
|
||||
Timestamp: uint64(s.StartTime.UnixNano()),
|
||||
DurationNano: s.DurationNano,
|
||||
HasError: s.HasError,
|
||||
Name: s.Name,
|
||||
Level: level,
|
||||
Events: s.UnmarshalledEvents(),
|
||||
Attributes: make(map[string]any),
|
||||
Resource: make(map[string]string),
|
||||
}
|
||||
if len(selectFields) == 0 {
|
||||
return span
|
||||
}
|
||||
for _, field := range selectFields {
|
||||
switch field.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
if v, ok := s.ResourcesString[field.Name]; ok && v != "" {
|
||||
span.Resource[field.Name] = v
|
||||
}
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
if v := s.AttributeValue(field.Name); v != nil {
|
||||
span.Attributes[field.Name] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
func NewMissingParentFlamegraphSpan(node *FlamegraphSpan) *FlamegraphSpan {
|
||||
return &FlamegraphSpan{
|
||||
SpanID: node.ParentSpanID,
|
||||
Name: "Missing Span",
|
||||
Timestamp: node.Timestamp,
|
||||
DurationNano: node.DurationNano,
|
||||
Events: []Event{},
|
||||
Children: []*FlamegraphSpan{node},
|
||||
}
|
||||
}
|
||||
|
||||
// FlamegraphWindowSpanIDs collects all span IDs from a level window into a flat slice.
|
||||
func FlamegraphWindowSpanIDs(window []FlamegraphLevel) []string {
|
||||
total := 0
|
||||
for _, lvl := range window {
|
||||
total += len(lvl.SpanIDs)
|
||||
}
|
||||
ids := make([]string, 0, total)
|
||||
for _, lvl := range window {
|
||||
ids = append(ids, lvl.SpanIDs...)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// FlamegraphTrace holds the level wise tree built from minimal spans.
|
||||
type FlamegraphTrace struct {
|
||||
roots []*FlamegraphSpan
|
||||
nodeByID map[string]*FlamegraphSpan
|
||||
startTime uint64
|
||||
endTime uint64
|
||||
}
|
||||
|
||||
func NewFlamegraphTraceFromMinimal(spans []MinimalSpan) *FlamegraphTrace {
|
||||
t := &FlamegraphTrace{
|
||||
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
|
||||
}
|
||||
for i := range spans {
|
||||
node := spans[i].ToFlamegraphSpan()
|
||||
t.updateTimeRange(node.Timestamp, node.DurationNano)
|
||||
t.nodeByID[node.SpanID] = node
|
||||
}
|
||||
t.buildSpanTree()
|
||||
return t
|
||||
}
|
||||
|
||||
func NewFlamegraphTraceFromStorable(spans []StorableSpan, selectFields []telemetrytypes.TelemetryFieldKey) *FlamegraphTrace {
|
||||
t := &FlamegraphTrace{
|
||||
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
|
||||
}
|
||||
for i := range spans {
|
||||
node := NewFlamegraphSpanFromStorable(&spans[i], 0, selectFields) // level is set later by BFS
|
||||
t.updateTimeRange(node.Timestamp, node.DurationNano)
|
||||
t.nodeByID[node.SpanID] = node
|
||||
}
|
||||
t.buildSpanTree()
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) GetAllLevels() [][]*FlamegraphSpan {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSelectedLevels returns the window of levels around selectedSpanID with sampling applied to dense levels.
|
||||
func (t *FlamegraphTrace) GetSelectedLevels(selectedSpanID string, levelLimit, spansPerLevel, topLatencyCount, bucketCount int) []FlamegraphLevel {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) EnrichSelectedSpans(selectedSpans []FlamegraphLevel, fullSpans []StorableSpan, selectFields []telemetrytypes.TelemetryFieldKey) [][]*FlamegraphSpan {
|
||||
fullByID := make(map[string]*StorableSpan, len(fullSpans))
|
||||
for i := range fullSpans {
|
||||
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
|
||||
}
|
||||
|
||||
result := make([][]*FlamegraphSpan, len(selectedSpans))
|
||||
for i, lvl := range selectedSpans {
|
||||
result[i] = make([]*FlamegraphSpan, 0, len(lvl.SpanIDs))
|
||||
for _, spanID := range lvl.SpanIDs {
|
||||
if full, ok := fullByID[spanID]; ok {
|
||||
result[i] = append(result[i], NewFlamegraphSpanFromStorable(full, lvl.Level, selectFields))
|
||||
} else if lean, ok := t.nodeByID[spanID]; ok {
|
||||
result[i] = append(result[i], lean)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) updateTimeRange(timestamp, durationNano uint64) {
|
||||
if t.startTime == 0 || timestamp < t.startTime {
|
||||
t.startTime = timestamp
|
||||
}
|
||||
if end := timestamp + durationNano; end > t.endTime {
|
||||
t.endTime = end
|
||||
}
|
||||
}
|
||||
|
||||
func (t *FlamegraphTrace) buildSpanTree() {
|
||||
for _, node := range t.nodeByID {
|
||||
if node.ParentSpanID != "" {
|
||||
if parent, ok := t.nodeByID[node.ParentSpanID]; ok {
|
||||
parent.Children = append(parent.Children, node)
|
||||
} else {
|
||||
missing := NewMissingParentFlamegraphSpan(node)
|
||||
t.nodeByID[missing.SpanID] = missing
|
||||
t.roots = append(t.roots, missing)
|
||||
}
|
||||
} else if flamegraphSpanIndex(t.roots, node.SpanID) == -1 {
|
||||
t.roots = append(t.roots, node)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(t.roots, func(i, j int) bool {
|
||||
if t.roots[i].Timestamp == t.roots[j].Timestamp {
|
||||
return t.roots[i].SpanID < t.roots[j].SpanID
|
||||
}
|
||||
return t.roots[i].Timestamp < t.roots[j].Timestamp
|
||||
})
|
||||
}
|
||||
|
||||
func flamegraphSpanIndex(spans []*FlamegraphSpan, spanID string) int {
|
||||
for i, s := range spans {
|
||||
if s.SpanID == spanID {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -30,7 +30,6 @@ type TraceStore interface {
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
|
||||
GetSpanCountByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
GetSpanDurationByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
|
||||
@@ -164,17 +164,6 @@ func (item *MinimalSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
|
||||
}
|
||||
}
|
||||
|
||||
func (item *MinimalSpan) ToFlamegraphSpan() *FlamegraphSpan {
|
||||
return &FlamegraphSpan{
|
||||
SpanID: item.SpanID,
|
||||
ParentSpanID: item.ParentSpanID,
|
||||
Timestamp: uint64(item.StartTime.UnixNano()),
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
Children: make([]*FlamegraphSpan, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// NewMissingWaterfallSpan creates a synthetic placeholder span for a parent that has no recorded data.
|
||||
func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano uint64) *WaterfallSpan {
|
||||
return &WaterfallSpan{
|
||||
@@ -278,19 +267,6 @@ func (ws *WaterfallSpan) getPathToSelectedSpanID(selectedSpanID string) ([]strin
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (item *StorableSpan) AttributeValue(name string) any {
|
||||
if v, ok := item.AttributesString[name]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := item.AttributesNumber[name]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := item.AttributesBool[name]; ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (item *StorableSpan) Attributes() map[string]any {
|
||||
attributes := make(map[string]any, len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool))
|
||||
for k, v := range item.AttributesString {
|
||||
@@ -320,7 +296,7 @@ func (item *StorableSpan) UnmarshalledEvents() []Event {
|
||||
func (item *StorableSpan) UnmarshalledRefs() []OtelSpanRef {
|
||||
refs := []OtelSpanRef{}
|
||||
if err := json.Unmarshal([]byte(item.References), &refs); err != nil {
|
||||
return []OtelSpanRef{} // skip malformed values
|
||||
return nil // skip malformed values
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
140
tests/fixtures/auth.py
vendored
140
tests/fixtures/auth.py
vendored
@@ -56,18 +56,11 @@ def _login(signoz: types.SigNoz, email: str, password: str) -> str:
|
||||
return login.json()["data"]["accessToken"]
|
||||
|
||||
|
||||
def register_admin(
|
||||
signoz: types.SigNoz,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "create_user_admin",
|
||||
base_path: str = "",
|
||||
) -> types.Operation:
|
||||
"""Register the first admin (creates the org), under base_path. Reuse-wrapped."""
|
||||
|
||||
def create() -> types.Operation:
|
||||
@pytest.fixture(name="create_user_admin", scope="package")
|
||||
def create_user_admin(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
|
||||
def create() -> None:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{base_path}/api/v1/register"),
|
||||
signoz.self.host_configs["8080"].get("/api/v1/register"),
|
||||
json={
|
||||
"name": USER_ADMIN_NAME,
|
||||
"orgName": "",
|
||||
@@ -90,7 +83,7 @@ def register_admin(
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
cache_key,
|
||||
"create_user_admin",
|
||||
lambda: types.Operation(name=""),
|
||||
create,
|
||||
delete,
|
||||
@@ -98,86 +91,86 @@ def register_admin(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_admin", scope="package")
|
||||
def create_user_admin(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
|
||||
return register_admin(signoz, request, pytestconfig)
|
||||
|
||||
|
||||
def session_context_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str], dict]:
|
||||
"""Build a callable that fetches the session context for an email (under base_path)."""
|
||||
|
||||
def fetch_session_context(email: str) -> dict:
|
||||
@pytest.fixture(name="get_session_context", scope="function")
|
||||
def get_session_context(signoz: types.SigNoz) -> Callable[[str, str], str]:
|
||||
def _get_session_context(email: str) -> str:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
|
||||
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
|
||||
params={
|
||||
"email": email,
|
||||
"ref": f"{signoz.self.host_configs['8080'].base()}",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
return response.json()["data"]
|
||||
|
||||
return fetch_session_context
|
||||
|
||||
|
||||
@pytest.fixture(name="get_session_context", scope="function")
|
||||
def get_session_context(signoz: types.SigNoz) -> Callable[[str], dict]:
|
||||
return session_context_getter(signoz)
|
||||
|
||||
|
||||
def token_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str, str], str]:
|
||||
"""Build a callable that logs in (email/password) and returns the access token (under base_path)."""
|
||||
|
||||
def fetch_token(email: str, password: str) -> str:
|
||||
context = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
|
||||
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert context.status_code == HTTPStatus.OK
|
||||
org_id = context.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
login = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/email_password"),
|
||||
json={"email": email, "password": password, "orgId": org_id},
|
||||
timeout=5,
|
||||
)
|
||||
assert login.status_code == HTTPStatus.OK
|
||||
return login.json()["data"]["accessToken"]
|
||||
|
||||
return fetch_token
|
||||
return _get_session_context
|
||||
|
||||
|
||||
@pytest.fixture(name="get_token", scope="function")
|
||||
def get_token(signoz: types.SigNoz) -> Callable[[str, str], str]:
|
||||
return token_getter(signoz)
|
||||
|
||||
|
||||
def tokens_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str, str], tuple[str, str]]:
|
||||
"""Build a callable that logs in and returns the (access, refresh) token pair (under base_path)."""
|
||||
|
||||
def fetch_tokens(email: str, password: str) -> tuple[str, str]:
|
||||
context = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
|
||||
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
|
||||
def _get_token(email: str, password: str) -> str:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
|
||||
params={
|
||||
"email": email,
|
||||
"ref": f"{signoz.self.host_configs['8080'].base()}",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert context.status_code == HTTPStatus.OK
|
||||
org_id = context.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
login = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/email_password"),
|
||||
json={"email": email, "password": password, "orgId": org_id},
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
org_id = response.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
|
||||
json={
|
||||
"email": email,
|
||||
"password": password,
|
||||
"orgId": org_id,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert login.status_code == HTTPStatus.OK
|
||||
data = login.json()["data"]
|
||||
return data["accessToken"], data["refreshToken"]
|
||||
|
||||
return fetch_tokens
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
return response.json()["data"]["accessToken"]
|
||||
|
||||
return _get_token
|
||||
|
||||
|
||||
@pytest.fixture(name="get_tokens", scope="function")
|
||||
def get_tokens(signoz: types.SigNoz) -> Callable[[str, str], tuple[str, str]]:
|
||||
return tokens_getter(signoz)
|
||||
def _get_tokens(email: str, password: str) -> str:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
|
||||
params={
|
||||
"email": email,
|
||||
"ref": f"{signoz.self.host_configs['8080'].base()}",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
org_id = response.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
|
||||
json={
|
||||
"email": email,
|
||||
"password": password,
|
||||
"orgId": org_id,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
access_token = response.json()["data"]["accessToken"]
|
||||
refresh_token = response.json()["data"]["refreshToken"]
|
||||
return access_token, refresh_token
|
||||
|
||||
return _get_tokens
|
||||
|
||||
|
||||
@pytest.fixture(name="apply_license", scope="package")
|
||||
@@ -277,7 +270,6 @@ def add_license(
|
||||
signoz: types.SigNoz,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str], # pylint: disable=redefined-outer-name
|
||||
base_path: str = "",
|
||||
) -> None:
|
||||
make_http_mocks(
|
||||
signoz.zeus,
|
||||
@@ -316,7 +308,7 @@ def add_license(
|
||||
access_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
url=signoz.self.host_configs["8080"].get(f"{base_path}/api/v3/licenses"),
|
||||
url=signoz.self.host_configs["8080"].get("/api/v3/licenses"),
|
||||
json={"key": "secret-key"},
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
timeout=5,
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from selenium import webdriver
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license, assert_user_has_role
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
|
||||
|
||||
# SigNoz is served under /signoz, so the OIDC callback registered with the IdP
|
||||
# must include the prefix to match the backend-generated redirect URI.
|
||||
BASE_PATH = "/signoz"
|
||||
OIDC_CALLBACK_PATH = f"{BASE_PATH}/api/v1/complete/oidc"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Applies a license to the signoz instance. add_license is a plain function
|
||||
called from the test (function scope), so the function-scoped make_http_mocks
|
||||
fixture is safe to use; base_path prefixes the licensing API call.
|
||||
"""
|
||||
add_license(signoz, make_http_mocks, get_token, base_path=BASE_PATH)
|
||||
|
||||
|
||||
def test_create_auth_domain(
|
||||
signoz: SigNoz,
|
||||
idp: TestContainerIDP, # pylint: disable=unused-argument
|
||||
create_oidc_client: Callable[[str, str], None],
|
||||
get_oidc_settings: Callable[[str], dict],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Creates an OIDC auth domain in SigNoz served under a base path. The callback
|
||||
registered with the IdP carries the /signoz prefix.
|
||||
"""
|
||||
client_id = f"oidc.basepath.test.{signoz.self.host_configs['8080'].address}:{signoz.self.host_configs['8080'].port}"
|
||||
# Create an oidc client in the idp with the prefixed callback.
|
||||
create_oidc_client(client_id, OIDC_CALLBACK_PATH)
|
||||
|
||||
# Get the oidc settings from keycloak.
|
||||
settings = get_oidc_settings(client_id)
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
|
||||
json={
|
||||
"name": "oidc.basepath.test",
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "oidc",
|
||||
"oidcConfig": {
|
||||
"clientId": settings["client_id"],
|
||||
"clientSecret": settings["client_secret"],
|
||||
# Change the hostname of the issuer to the internal resolvable hostname of the idp
|
||||
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
|
||||
"issuerAlias": settings["issuer"],
|
||||
"getUserInfo": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
|
||||
def test_oidc_authn(
|
||||
signoz: SigNoz,
|
||||
idp: TestContainerIDP,
|
||||
driver: webdriver.Chrome,
|
||||
create_user_idp: Callable[[str, str, bool, str, str], None],
|
||||
idp_login: Callable[[str, str], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
"""
|
||||
Tests the OIDC authn flow when SigNoz is served under a base path. The login
|
||||
URL the backend produces (and thus the IdP callback) carries the /signoz
|
||||
prefix; the e2e browser login must complete and create the user.
|
||||
"""
|
||||
# Create a user in the idp.
|
||||
create_user_idp("viewer@oidc.basepath.test", "password123", True)
|
||||
|
||||
# Get the session context from signoz which will give the OIDC login URL.
|
||||
session_context = get_session_context("viewer@oidc.basepath.test")
|
||||
|
||||
assert len(session_context["orgs"]) == 1
|
||||
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
|
||||
|
||||
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
|
||||
|
||||
# change the url to the external resolvable hostname of the idp
|
||||
parsed_url = urlparse(url)
|
||||
actual_url = f"{idp.container.host_configs['6060'].get(parsed_url.path)}?{parsed_url.query}"
|
||||
|
||||
driver.get(actual_url)
|
||||
idp_login("viewer@oidc.basepath.test", "password123")
|
||||
|
||||
# Assert that the user was created in signoz (lookup under the base path).
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
users = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/signoz/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert users.status_code == HTTPStatus.OK, users.text
|
||||
user = next((u for u in users.json()["data"] if u["email"] == "viewer@oidc.basepath.test"), None)
|
||||
assert user is not None, "User with email 'viewer@oidc.basepath.test' not found"
|
||||
|
||||
user_with_roles = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/signoz/api/v2/users/{user['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert user_with_roles.status_code == HTTPStatus.OK, user_with_roles.text
|
||||
assert_user_has_role(user_with_roles.json()["data"], "signoz-viewer")
|
||||
@@ -1,117 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from selenium import webdriver
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license, assert_user_has_role
|
||||
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
|
||||
|
||||
# SigNoz is served under /signoz, so the SAML ACS registered with the IdP must
|
||||
# include the prefix to match the backend-generated AssertionConsumerServiceURL.
|
||||
BASE_PATH = "/signoz"
|
||||
SAML_CALLBACK_PATH = f"{BASE_PATH}/api/v1/complete/saml"
|
||||
|
||||
|
||||
def test_apply_license(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Applies a license to the signoz instance. add_license is a plain function
|
||||
called from the test (function scope), so the function-scoped make_http_mocks
|
||||
fixture is safe to use; base_path prefixes the licensing API call.
|
||||
"""
|
||||
add_license(signoz, make_http_mocks, get_token, base_path=BASE_PATH)
|
||||
|
||||
|
||||
def test_create_auth_domain(
|
||||
signoz: SigNoz,
|
||||
idp: TestContainerIDP, # pylint: disable=unused-argument
|
||||
create_saml_client: Callable[[str, str], None],
|
||||
get_saml_settings: Callable[[], dict],
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
"""
|
||||
Creates a SAML auth domain in SigNoz served under a base path. The ACS
|
||||
registered with the IdP carries the /signoz prefix.
|
||||
"""
|
||||
# Create a saml client in the idp with the prefixed ACS.
|
||||
create_saml_client("saml.basepath.test", SAML_CALLBACK_PATH)
|
||||
|
||||
# Get the saml settings from keycloak.
|
||||
settings = get_saml_settings()
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
|
||||
json={
|
||||
"name": "saml.basepath.test",
|
||||
"config": {
|
||||
"ssoEnabled": True,
|
||||
"ssoType": "saml",
|
||||
"samlConfig": {
|
||||
"samlEntity": settings["entityID"],
|
||||
"samlIdp": settings["singleSignOnServiceLocation"],
|
||||
"samlCert": settings["certificate"],
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.CREATED
|
||||
|
||||
|
||||
def test_saml_authn(
|
||||
signoz: SigNoz,
|
||||
idp: TestContainerIDP, # pylint: disable=unused-argument
|
||||
driver: webdriver.Chrome,
|
||||
create_user_idp: Callable[[str, str, bool, str, str], None],
|
||||
idp_login: Callable[[str, str], None],
|
||||
get_token: Callable[[str, str], str],
|
||||
get_session_context: Callable[[str], dict],
|
||||
) -> None:
|
||||
"""
|
||||
Tests the SAML authn flow when SigNoz is served under a base path. The
|
||||
AssertionConsumerServiceURL in the AuthnRequest carries the /signoz prefix;
|
||||
the e2e browser login must complete and create the user.
|
||||
"""
|
||||
# Create a user in the idp.
|
||||
create_user_idp("viewer@saml.basepath.test", "password", True)
|
||||
|
||||
# Get the session context from signoz which will give the SAML login URL.
|
||||
session_context = get_session_context("viewer@saml.basepath.test")
|
||||
|
||||
assert len(session_context["orgs"]) == 1
|
||||
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
|
||||
|
||||
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
|
||||
|
||||
driver.get(url)
|
||||
idp_login("viewer@saml.basepath.test", "password")
|
||||
|
||||
# Assert that the user was created in signoz (lookup under the base path).
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
users = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/signoz/api/v2/users"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert users.status_code == HTTPStatus.OK, users.text
|
||||
user = next((u for u in users.json()["data"] if u["email"] == "viewer@saml.basepath.test"), None)
|
||||
assert user is not None, "User with email 'viewer@saml.basepath.test' not found"
|
||||
|
||||
user_with_roles = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/signoz/api/v2/users/{user['id']}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert user_with_roles.status_code == HTTPStatus.OK, user_with_roles.text
|
||||
assert_user_has_role(user_with_roles.json()["data"], "signoz-viewer")
|
||||
@@ -1,57 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import register_admin, session_context_getter, token_getter
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
# SigNoz is served under this URL path prefix for the base-path suite. The auth
|
||||
# helpers from fixtures/auth.py are reused via their factories with this prefix,
|
||||
# so these fixtures shadow the same-named root ones without duplicating logic.
|
||||
# Only the path component is read by global.ExternalPath(), which derives the
|
||||
# http.StripPrefix route prefix.
|
||||
BASE_PATH = "/signoz"
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_base_path( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
"""
|
||||
Package-scoped SigNoz served under BASE_PATH. Sets SIGNOZ_GLOBAL_EXTERNAL__URL
|
||||
with the prefix so the backend derives the http.StripPrefix route prefix.
|
||||
"""
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_base_path",
|
||||
env_overrides={"SIGNOZ_GLOBAL_EXTERNAL__URL": f"http://localhost:8080{BASE_PATH}"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_admin", scope="package")
|
||||
def create_user_admin_base_path(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
|
||||
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_base_path", base_path=BASE_PATH)
|
||||
|
||||
|
||||
@pytest.fixture(name="get_token", scope="function")
|
||||
def get_token(signoz: types.SigNoz) -> Callable[[str, str], str]:
|
||||
return token_getter(signoz, BASE_PATH)
|
||||
|
||||
|
||||
@pytest.fixture(name="get_session_context", scope="function")
|
||||
def get_session_context(signoz: types.SigNoz) -> Callable[[str], dict]:
|
||||
return session_context_getter(signoz, BASE_PATH)
|
||||
@@ -483,7 +483,7 @@ def test_enable_metrics_provisions_dashboards(
|
||||
assert isinstance(dashboards_in_service, list) and len(dashboards_in_service) > 0, "assets.dashboards should be non-empty after enabling metrics"
|
||||
provisioned_ids = set()
|
||||
for dash in dashboards_in_service:
|
||||
assert "integrationDashboard" in dash, "Integration dashboard entry missing"
|
||||
assert "integrationDashboard" in dash, f"Integration dashboard entry missing"
|
||||
try:
|
||||
uuid.UUID(dash["integrationDashboard"]["id"])
|
||||
except ValueError as err:
|
||||
|
||||
Reference in New Issue
Block a user