Compare commits

..

1 Commits

Author SHA1 Message Date
aks07
22cd13cdca feat: add trace details v3 waterfall
Virtualized waterfall table with:
- Left panel: span tree with collapse/expand
- Right panel: timeline duration bars
- ResizableBox for sidebar width control
- Imperative hover highlighting (no re-renders)
- Memoized SpanOverview and SpanDuration
- Loading bar during data fetch
- InfraMetrics grid changed to CSS grid
- EmptyLogsSearch flex-wrap for narrow panels
2026-04-10 10:02:40 +05:30
51 changed files with 4042 additions and 1270 deletions

View File

@@ -1,169 +1,5 @@
components:
schemas:
Aio11YtypesCacheMode:
enum:
- subtract
- additive
- unknown
nullable: true
type: string
Aio11YtypesGettablePricingRule:
properties:
cache_mode:
$ref: '#/components/schemas/Aio11YtypesCacheMode'
cost_cache_read:
format: double
type: number
cost_cache_write:
format: double
type: number
cost_input:
format: double
type: number
cost_output:
format: double
type: number
created_at:
format: date-time
type: string
created_by:
type: string
enabled:
type: boolean
id:
type: string
is_override:
type: boolean
model_name:
type: string
model_pattern:
items:
type: string
nullable: true
type: array
synced_at:
format: date-time
nullable: true
type: string
unit:
$ref: '#/components/schemas/Aio11YtypesUnit'
updated_at:
format: date-time
type: string
updated_by:
type: string
required:
- id
- model_name
- model_pattern
- unit
- cache_mode
- cost_input
- cost_output
- cost_cache_read
- cost_cache_write
- is_override
- enabled
- created_at
- updated_at
- created_by
- updated_by
type: object
Aio11YtypesListPricingRulesResponse:
properties:
items:
items:
$ref: '#/components/schemas/Aio11YtypesGettablePricingRule'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
total:
type: integer
required:
- items
- total
- offset
- limit
type: object
Aio11YtypesPostablePricingRule:
properties:
cache_mode:
$ref: '#/components/schemas/Aio11YtypesCacheMode'
cost_cache_read:
format: double
type: number
cost_cache_write:
format: double
type: number
cost_input:
format: double
type: number
cost_output:
format: double
type: number
enabled:
type: boolean
model_name:
type: string
model_pattern:
items:
type: string
nullable: true
type: array
unit:
$ref: '#/components/schemas/Aio11YtypesUnit'
required:
- model_name
- model_pattern
- unit
- cache_mode
- cost_input
- cost_output
- cost_cache_read
- cost_cache_write
- enabled
type: object
Aio11YtypesUnit:
enum:
- per_million_tokens
- per_token
nullable: true
type: string
Aio11YtypesUpdatablePricingRule:
properties:
cache_mode:
$ref: '#/components/schemas/Aio11YtypesCacheMode'
cost_cache_read:
nullable: true
type: number
cost_cache_write:
nullable: true
type: number
cost_input:
nullable: true
type: number
cost_output:
nullable: true
type: number
enabled:
nullable: true
type: boolean
is_override:
nullable: true
type: boolean
model_name:
nullable: true
type: string
model_pattern:
items:
type: string
type: array
unit:
$ref: '#/components/schemas/Aio11YtypesUnit'
type: object
AuthtypesAttributeMapping:
properties:
email:
@@ -3222,303 +3058,6 @@ info:
version: ""
openapi: 3.0.3
paths:
/api/v1/ai-o11y/pricing/rules:
get:
deprecated: false
description: Returns all LLM pricing rules for the authenticated org, with pagination.
operationId: ListPricingRules
parameters:
- in: query
name: offset
schema:
type: integer
- in: query
name: limit
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Aio11YtypesListPricingRulesResponse'
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
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List pricing rules
tags:
- ai-o11y
post:
deprecated: false
description: Creates a new LLM pricing rule for the org. Always sets is_override
= true.
operationId: CreatePricingRule
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Aio11YtypesPostablePricingRule'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Aio11YtypesGettablePricingRule'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create a pricing rule
tags:
- ai-o11y
/api/v1/ai-o11y/pricing/rules/{id}:
delete:
deprecated: false
description: Hard-deletes a pricing rule. If auto-synced, it will be recreated
on the next sync cycle.
operationId: DeletePricingRule
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"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:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete a pricing rule
tags:
- ai-o11y
get:
deprecated: false
description: Returns a single LLM pricing rule by ID.
operationId: GetPricingRule
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Aio11YtypesGettablePricingRule'
status:
type: string
required:
- status
- data
type: object
description: OK
"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 a pricing rule
tags:
- ai-o11y
put:
deprecated: false
description: Partially updates an existing pricing rule. Changing any cost field
sets is_override = true.
operationId: UpdatePricingRule
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Aio11YtypesUpdatablePricingRule'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Aio11YtypesGettablePricingRule'
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:
- ADMIN
- tokenizer:
- ADMIN
summary: Update a pricing rule
tags:
- ai-o11y
/api/v1/authz/check:
post:
deprecated: false
@@ -3770,7 +3309,7 @@ paths:
schema:
$ref: '#/components/schemas/CloudintegrationtypesPostableAccount'
responses:
"201":
"200":
content:
application/json:
schema:
@@ -3783,7 +3322,7 @@ paths:
- status
- data
type: object
description: Created
description: OK
"401":
content:
application/json:
@@ -4144,11 +3683,6 @@ paths:
provider
operationId: ListServicesMetadata
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true
@@ -4201,11 +3735,6 @@ paths:
description: This endpoint gets a service for the specified cloud provider
operationId: GetService
parameters:
- in: query
name: cloud_integration_id
required: false
schema:
type: string
- in: path
name: cloud_provider
required: true

View File

@@ -28,7 +28,7 @@ import type {
CloudintegrationtypesPostableAgentCheckInDTO,
CloudintegrationtypesUpdatableAccountDTO,
CloudintegrationtypesUpdatableServiceDTO,
CreateAccount201,
CreateAccount200,
CreateAccountPathParameters,
DisconnectAccountPathParameters,
GetAccount200,
@@ -36,12 +36,10 @@ import type {
GetConnectionCredentials200,
GetConnectionCredentialsPathParameters,
GetService200,
GetServiceParams,
GetServicePathParameters,
ListAccounts200,
ListAccountsPathParameters,
ListServicesMetadata200,
ListServicesMetadataParams,
ListServicesMetadataPathParameters,
RenderErrorResponseDTO,
UpdateAccountPathParameters,
@@ -262,7 +260,7 @@ export const createAccount = (
cloudintegrationtypesPostableAccountDTO: BodyType<CloudintegrationtypesPostableAccountDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAccount201>({
return GeneratedAPIInstance<CreateAccount200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/accounts`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -942,25 +940,19 @@ export const invalidateGetConnectionCredentials = async (
*/
export const listServicesMetadata = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListServicesMetadata200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services`,
method: 'GET',
params,
signal,
});
};
export const getListServicesMetadataQueryKey = (
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services`,
...(params ? [params] : []),
] as const;
export const getListServicesMetadataQueryKey = ({
cloudProvider,
}: ListServicesMetadataPathParameters) => {
return [`/api/v1/cloud_integrations/${cloudProvider}/services`] as const;
};
export const getListServicesMetadataQueryOptions = <
@@ -968,7 +960,6 @@ export const getListServicesMetadataQueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -980,12 +971,11 @@ export const getListServicesMetadataQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getListServicesMetadataQueryKey({ cloudProvider }, params);
queryOptions?.queryKey ?? getListServicesMetadataQueryKey({ cloudProvider });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listServicesMetadata>>
> = ({ signal }) => listServicesMetadata({ cloudProvider }, params, signal);
> = ({ signal }) => listServicesMetadata({ cloudProvider }, signal);
return {
queryKey,
@@ -1013,7 +1003,6 @@ export function useListServicesMetadata<
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listServicesMetadata>>,
@@ -1024,7 +1013,6 @@ export function useListServicesMetadata<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListServicesMetadataQueryOptions(
{ cloudProvider },
params,
options,
);
@@ -1043,11 +1031,10 @@ export function useListServicesMetadata<
export const invalidateListServicesMetadata = async (
queryClient: QueryClient,
{ cloudProvider }: ListServicesMetadataPathParameters,
params?: ListServicesMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }, params) },
{ queryKey: getListServicesMetadataQueryKey({ cloudProvider }) },
options,
);
@@ -1060,24 +1047,21 @@ export const invalidateListServicesMetadata = async (
*/
export const getService = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetService200>({
url: `/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
method: 'GET',
params,
signal,
});
};
export const getGetServiceQueryKey = (
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
) => {
export const getGetServiceQueryKey = ({
cloudProvider,
serviceId,
}: GetServicePathParameters) => {
return [
`/api/v1/cloud_integrations/${cloudProvider}/services/${serviceId}`,
...(params ? [params] : []),
] as const;
};
@@ -1086,7 +1070,6 @@ export const getGetServiceQueryOptions = <
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
@@ -1098,12 +1081,11 @@ export const getGetServiceQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetServiceQueryKey({ cloudProvider, serviceId }, params);
queryOptions?.queryKey ?? getGetServiceQueryKey({ cloudProvider, serviceId });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getService>>> = ({
signal,
}) => getService({ cloudProvider, serviceId }, params, signal);
}) => getService({ cloudProvider, serviceId }, signal);
return {
queryKey,
@@ -1129,7 +1111,6 @@ export function useGetService<
TError = ErrorType<RenderErrorResponseDTO>
>(
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getService>>,
@@ -1140,7 +1121,6 @@ export function useGetService<
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceQueryOptions(
{ cloudProvider, serviceId },
params,
options,
);
@@ -1159,11 +1139,10 @@ export function useGetService<
export const invalidateGetService = async (
queryClient: QueryClient,
{ cloudProvider, serviceId }: GetServicePathParameters,
params?: GetServiceParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }, params) },
{ queryKey: getGetServiceQueryKey({ cloudProvider, serviceId }) },
options,
);

View File

@@ -3589,7 +3589,7 @@ export type ListAccounts200 = {
export type CreateAccountPathParameters = {
cloudProvider: string;
};
export type CreateAccount201 = {
export type CreateAccount200 = {
data: CloudintegrationtypesGettableAccountWithConnectionArtifactDTO;
/**
* @type string
@@ -3647,14 +3647,6 @@ export type GetConnectionCredentials200 = {
export type ListServicesMetadataPathParameters = {
cloudProvider: string;
};
export type ListServicesMetadataParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type ListServicesMetadata200 = {
data: CloudintegrationtypesGettableServicesMetadataDTO;
/**
@@ -3667,14 +3659,6 @@ export type GetServicePathParameters = {
cloudProvider: string;
serviceId: string;
};
export type GetServiceParams = {
/**
* @type string
* @description undefined
*/
cloud_integration_id?: string;
};
export type GetService200 = {
data: CloudintegrationtypesServiceDTO;
/**

View File

@@ -0,0 +1,4 @@
.timeline-v3-container {
// flex: 1;
overflow: visible;
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { useMeasure } from 'react-use';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
getIntervals,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const isDarkMode = useIsDarkMode();
useEffect(() => {
const spread = endTimestamp - startTimestamp;
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const intervals = getIntervals(intervalisedSpread, spread, offsetTimestamp);
setIntervals(intervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <div />;
}
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={timelineHeight * 2.5}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>
</div>
);
}
export default TimelineV3;

View File

@@ -0,0 +1,93 @@
import {
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
export function getMinimumIntervalsBasedOnWidth(width: number): number {
if (width < 640) {
return 3;
}
if (width < 768) {
return 4;
}
if (width < 1024) {
return 5;
}
return 6;
}
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
let tempBaseSpread = baseSpread;
let elapsedIntervals = 0;
while (tempBaseSpread && intervals.length < 20) {
let intervalTime: number;
if (tempBaseSpread <= 1.5 * intervalSpreadNormalized) {
intervalTime = elapsedIntervals + tempBaseSpread;
tempBaseSpread = 0;
} else {
intervalTime = elapsedIntervals + intervalSpreadNormalized;
tempBaseSpread -= intervalSpreadNormalized;
}
elapsedIntervals = intervalTime;
const labelTime = offsetTimestamp + intervalTime;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (intervalTime / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -33,6 +33,125 @@ const themeColors = {
purple: '#800080',
cyan: '#00FFFF',
},
traceDetailColorsV3: {
// Blues
dodgerBlue: '#2F80ED',
royalBlue: '#3366E6',
steelBlue: '#4682B4',
// Teals / Cyans
turquoise: '#00CEC9',
lagoon: '#1ABC9C',
cyanBright: '#22A6F2',
// Greens
emeraldGreen: '#27AE60',
mediumSeaGreen: '#3CB371',
limeGreen: '#A3E635',
// Yellows / Golds
festivalYellow: '#F2C94C',
sunflower: '#FFD93D',
warmAmber: '#FFCA28',
// Purples / Violets
mediumPurple: '#BB6BD9',
royalPurple: '#9B51E0',
orchid: '#DA77F2',
// Accent
neonViolet: '#C77DFF',
electricPurple: '#6C5CE7',
arcticBlue: '#48DBFB',
// Blues extended
blue1: '#1F63E0',
blue2: '#3A7AED',
blue3: '#5A9DF5',
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
// Cyans
cyan1: '#00B0AA',
cyan2: '#33D6C2',
cyan3: '#66E9DA',
// Greens extended
green1: '#1E8449',
green2: '#2ECC71',
green3: '#58D68D',
green4: '#229954',
green5: '#27AE60',
green6: '#52BE80',
// Forest
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
// Lime
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#D4FFB0',
// Teals
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
teal4: '#1ABC9C',
teal5: '#48C9B0',
teal6: '#76D7C4',
// Yellows
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
// Gold
gold1: '#F39C12',
gold2: '#F1C40F',
gold3: '#F7DC6F',
gold4: '#B7950B',
gold5: '#F1C40F',
gold6: '#F4D03F',
// Mustard
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
// Aqua
aqua1: '#00BFFF',
aqua2: '#1E90FF',
aqua3: '#63B8FF',
// Purple extended
purple1: '#8E44AD',
purple2: '#9B59B6',
purple3: '#BB8FCE',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
violet4: '#7D3C98',
violet5: '#8E44AD',
violet6: '#9B59B6',
// Lavender
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
// Oranges (safe ones, not red-ish)
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',

View File

@@ -123,6 +123,7 @@
&__row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
max-width: 825px;
gap: 25px;

View File

@@ -11,6 +11,12 @@
}
}
.infra-metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.infra-metrics-card {
margin: 1rem 0;
height: 300px;

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import { Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -163,16 +163,16 @@ function NodeMetrics({
);
};
return (
<Row gutter={24}>
<div className="infra-metrics-grid">
{queries.map((query, idx) => (
<Col span={12} key={widgetInfo[idx].title}>
<div key={widgetInfo[idx].title}>
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</Col>
</div>
))}
</Row>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import { Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -146,16 +146,16 @@ function PodMetrics({
};
return (
<Row gutter={24}>
<div className="infra-metrics-grid">
{queries.map((query, idx) => (
<Col span={12} key={podWidgetInfo[idx].title}>
<div key={podWidgetInfo[idx].title}>
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</Col>
</div>
))}
</Row>
</div>
);
}

View File

@@ -7,6 +7,23 @@ export function hashFn(str: string): number {
return hash >>> 0;
}
export function colorToRgb(color: string): string {
// Handle hex colors
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
if (hexMatch) {
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
hexMatch[2],
16,
)}, ${parseInt(hexMatch[3], 16)}`;
}
// Handle rgb() colors
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
if (rgbMatch) {
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
}
return '136, 136, 136';
}
export function generateColor(
key: string,
colorMap: Record<string, string>,

View File

@@ -0,0 +1,60 @@
.event-tooltip-content {
font-family: Inter, sans-serif;
font-size: 12px;
color: #fff;
max-width: 300px;
&__header {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 6px;
}
&__name {
font-weight: 600;
margin-bottom: 2px;
color: rgb(14, 165, 233);
&.error {
color: rgb(239, 68, 68);
}
}
&__time {
font-size: 11px;
opacity: 0.8;
margin-bottom: 4px;
}
&__divider {
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: 6px 0;
}
&__attributes {
font-size: 11px;
}
&__kv {
margin-bottom: 2px;
line-height: 1.4;
word-break: break-all;
}
&__key {
opacity: 0.6;
}
&__value {
opacity: 0.9;
}
}

View File

@@ -0,0 +1,49 @@
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { Diamond } from 'lucide-react';
import { toFixed } from 'utils/toFixed';
import './EventTooltipContent.styles.scss';
export interface EventTooltipContentProps {
eventName: string;
timeOffsetMs: number;
isError: boolean;
attributeMap: Record<string, string>;
}
export function EventTooltipContent({
eventName,
timeOffsetMs,
isError,
attributeMap,
}: EventTooltipContentProps): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(timeOffsetMs);
return (
<div className="event-tooltip-content">
<div className="event-tooltip-content__header">
<Diamond size={10} />
<span>EVENT DETAILS</span>
</div>
<div className={`event-tooltip-content__name ${isError ? 'error' : ''}`}>
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} from start
</div>
{Object.keys(attributeMap).length > 0 && (
<>
<div className="event-tooltip-content__divider" />
<div className="event-tooltip-content__attributes">
{Object.entries(attributeMap).map(([key, value]) => (
<div key={key} className="event-tooltip-content__kv">
<span className="event-tooltip-content__key">{key}:</span>{' '}
<span className="event-tooltip-content__value">{value}</span>
</div>
))}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
.span-hover-card-popover {
.ant-popover-inner {
background-color: rgba(30, 30, 30, 0.95);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: none;
}
.ant-popover-inner-content {
padding: 0;
}
}
.span-hover-card-content {
font-family: Inter, sans-serif;
font-size: 12px;
color: #fff;
&__name {
font-weight: 600;
margin-bottom: 4px;
}
&__row {
line-height: 1.5;
}
}

View File

@@ -0,0 +1,94 @@
import { ReactNode } from 'react';
import { Popover } from 'antd';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
}
export interface SpanTooltipContentProps {
spanName: string;
color: string;
hasError: boolean;
relativeStartMs: number;
durationMs: number;
}
export function SpanTooltipContent({
spanName,
color,
hasError,
relativeStartMs,
durationMs,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } = convertTimeToRelevantUnit(
durationMs,
);
return (
<div className="span-hover-card-content">
<div className="span-hover-card-content__name" style={{ color }}>
{spanName}
</div>
<div className="span-hover-card-content__row">
Status: {hasError ? 'error' : 'ok'}
</div>
<div className="span-hover-card-content__row">
Start: {toFixed(relativeStartMs, 2)} ms
</div>
<div className="span-hover-card-content__row">
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
</div>
</div>
);
}
interface SpanHoverCardProps {
span: Span;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const durationMs = span.durationNano / 1e6;
const relativeStartMs = span.timestamp - traceMetadata.startTime;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = 'var(--bg-cherry-500)';
}
return (
<Popover
mouseEnterDelay={0.2}
content={
<SpanTooltipContent
spanName={span.name}
color={color}
hasError={span.hasError}
relativeStartMs={relativeStartMs}
durationMs={durationMs}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
>
{children}
</Popover>
);
}
export default SpanHoverCard;

View File

@@ -0,0 +1,297 @@
// Modal base styles
.add-span-to-funnel-modal {
&__loading-spinner {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
}
&-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-ink-500);
}
&-header {
border-bottom: none;
.ant-modal-title {
color: var(--bg-vanilla-100);
}
}
&-body {
padding: 14px 16px !important;
padding-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&-footer {
margin-top: 0;
background: var(--bg-ink-400);
border-top: 1px solid var(--bg-slate-500);
padding: 16px !important;
.add-span-to-funnel-modal {
&__save-button {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--bg-vanilla-100);
font-size: 12px;
font-weight: 500;
line-height: 24px;
width: 135px;
.ant-btn-icon {
display: flex;
}
&:disabled {
color: var(--bg-vanilla-400);
.ant-btn-icon {
svg {
stroke: var(--bg-vanilla-400);
}
}
}
}
&__discard-button {
background: var(--bg-slate-500);
}
}
.ant-btn {
border-radius: 2px;
padding: 4px 8px;
margin: 0 !important;
border: none;
box-shadow: none;
}
}
}
}
}
// Main modal styles
.add-span-to-funnel-modal {
// Common button styles
%button-base {
display: flex;
align-items: center;
font-family: Inter;
}
// Details view styles
&--details {
.traces-funnel-details {
height: unset;
&__steps-config {
width: unset;
border: none;
}
.funnel-step-wrapper {
gap: 15px;
}
.steps-content {
max-height: 500px;
}
}
}
// Search section
&__search {
display: flex;
gap: 12px;
margin-bottom: 14px;
align-items: center;
&-input {
flex: 1;
padding: 6px 8px;
background: var(--bg-ink-300);
.ant-input-prefix {
height: 18px;
margin-inline-end: 6px;
svg {
opacity: 0.4;
}
}
&,
input {
font-family: Inter;
font-size: 14px;
line-height: 18px;
letter-spacing: -0.07px;
font-weight: 400;
background: var(--bg-ink-300);
}
input::placeholder {
color: var(--bg-vanilla-400);
opacity: 0.4;
}
}
}
// Create button
&__create-button {
@extend %button-base;
width: 153px;
padding: 4px 8px;
justify-content: center;
gap: 4px;
flex-shrink: 0;
border-radius: 2px;
background: var(--bg-slate-500);
border: none;
box-shadow: none;
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 500;
line-height: 24px;
}
.funnel-item {
padding: 8px 8px 12px 16px;
&,
&:first-child {
border-radius: 6px;
}
&__header {
line-height: 20px;
}
&__details {
line-height: 18px;
}
}
// List section
&__list {
max-height: 400px;
overflow-y: scroll;
.funnels-empty {
&__content {
padding: 0;
}
}
.funnels-list {
gap: 8px;
.funnel-item {
padding: 8px 16px 12px;
&__details {
margin-top: 8px;
}
}
}
}
&__spinner {
height: 400px;
}
// Back button
&__back-button {
@extend %button-base;
gap: 6px;
color: var(--bg-vanilla-400);
font-size: 14px;
line-height: 20px;
margin-bottom: 14px;
}
// Details section
&__details {
display: flex;
flex-direction: column;
gap: 24px;
.funnel-configuration__steps {
padding: 0;
.funnel-step {
&__content .filters__service-and-span .ant-select {
width: 170px;
}
&__footer .error {
width: 25%;
}
}
.inter-step-config {
width: calc(100% - 104px);
}
}
.funnel-item__actions-popover {
display: none;
}
}
}
// Light mode styles
.lightMode {
.add-span-to-funnel-modal-container {
.ant-modal {
&-content,
&-header {
background: var(--bg-vanilla-100);
}
&-title {
color: var(--bg-ink-500);
}
&-footer {
border-top-color: var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.add-span-to-funnel-modal__discard-button {
background: var(--bg-vanilla-200);
color: var(--bg-ink-500);
}
}
}
}
.add-span-to-funnel-modal {
&__search-input {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-500);
input {
color: var(--bg-ink-500);
background: var(--bg-vanilla-100);
&::placeholder {
color: var(--bg-ink-400);
}
}
}
&__create-button {
background: var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
&__back-button {
color: var(--bg-ink-500);
&:hover {
color: var(--bg-ink-400);
}
}
&__details h3 {
color: var(--bg-ink-500);
}
}
}

View File

@@ -0,0 +1,294 @@
import { ChangeEvent, useEffect, useMemo, useState } from 'react';
import { LoadingOutlined } from '@ant-design/icons';
import { Button, Input, Spin } from 'antd';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SignozModal from 'components/SignozModal/SignozModal';
import {
useFunnelDetails,
useFunnelsList,
} from 'hooks/TracesFunnels/useFunnels';
import { isEqual } from 'lodash-es';
import { ArrowLeft, Check, Plus, Search } from 'lucide-react';
import FunnelConfiguration from 'pages/TracesFunnelDetails/components/FunnelConfiguration/FunnelConfiguration';
import { TracesFunnelsContentRenderer } from 'pages/TracesFunnels';
import CreateFunnel from 'pages/TracesFunnels/components/CreateFunnel/CreateFunnel';
import { FunnelListItem } from 'pages/TracesFunnels/components/FunnelsList/FunnelsList';
import {
FunnelProvider,
useFunnelContext,
} from 'pages/TracesFunnels/FunnelContext';
import { filterFunnelsByQuery } from 'pages/TracesFunnels/utils';
import { Span } from 'types/api/trace/getTraceV2';
import { FunnelData } from 'types/api/traceFunnels';
import './AddSpanToFunnelModal.styles.scss';
enum ModalView {
LIST = 'list',
DETAILS = 'details',
}
function FunnelDetailsView({
funnel,
span,
triggerAutoSave,
showNotifications,
onChangesDetected,
triggerDiscard,
}: {
funnel: FunnelData;
span: Span;
triggerAutoSave: boolean;
showNotifications: boolean;
onChangesDetected: (hasChanges: boolean) => void;
triggerDiscard: boolean;
}): JSX.Element {
const { handleRestoreSteps, steps } = useFunnelContext();
// Track changes between current steps and original steps
useEffect(() => {
const hasChanges = !isEqual(steps, funnel.steps);
if (onChangesDetected) {
onChangesDetected(hasChanges);
}
}, [steps, funnel.steps, onChangesDetected]);
// Handle discard when triggered from parent
useEffect(() => {
if (triggerDiscard && funnel.steps) {
handleRestoreSteps(funnel.steps);
}
}, [triggerDiscard, funnel.steps, handleRestoreSteps]);
return (
<div className="add-span-to-funnel-modal__details">
<FunnelListItem
funnel={funnel}
shouldRedirectToTracesListOnDeleteSuccess={false}
isSpanDetailsPage
/>
<FunnelConfiguration
funnel={funnel}
isTraceDetailsPage
span={span}
triggerAutoSave={triggerAutoSave}
showNotifications={showNotifications}
/>
</div>
);
}
interface AddSpanToFunnelModalProps {
isOpen: boolean;
onClose: () => void;
span: Span;
}
function AddSpanToFunnelModal({
isOpen,
onClose,
span,
}: AddSpanToFunnelModalProps): JSX.Element {
const [activeView, setActiveView] = useState<ModalView>(ModalView.LIST);
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedFunnelId, setSelectedFunnelId] = useState<string | undefined>(
undefined,
);
const [isCreateModalOpen, setIsCreateModalOpen] = useState<boolean>(false);
const [triggerSave, setTriggerSave] = useState<boolean>(false);
const [isUnsavedChanges, setIsUnsavedChanges] = useState<boolean>(false);
const [triggerDiscard, setTriggerDiscard] = useState<boolean>(false);
const [isCreatedFromSpan, setIsCreatedFromSpan] = useState<boolean>(false);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchQuery(e.target.value);
};
const { data, isLoading, isError, isFetching } = useFunnelsList();
const filteredData = useMemo(
() =>
filterFunnelsByQuery(data?.payload || [], searchQuery).sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
),
[data?.payload, searchQuery],
);
const {
data: funnelDetails,
isLoading: isFunnelDetailsLoading,
isFetching: isFunnelDetailsFetching,
} = useFunnelDetails({
funnelId: selectedFunnelId,
});
const handleFunnelClick = (funnel: FunnelData): void => {
setSelectedFunnelId(funnel.funnel_id);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(false);
};
const handleBack = (): void => {
setActiveView(ModalView.LIST);
setSelectedFunnelId(undefined);
setIsUnsavedChanges(false);
setTriggerSave(false);
setIsCreatedFromSpan(false);
};
const handleCreateNewClick = (): void => {
setIsCreateModalOpen(true);
};
const handleSaveFunnel = (): void => {
setTriggerSave(true);
// Reset trigger after a brief moment to allow the save to be processed
setTimeout(() => {
setTriggerSave(false);
onClose();
}, 100);
};
const handleDiscard = (): void => {
setTriggerDiscard(true);
// Reset trigger after a brief moment
setTimeout(() => {
setTriggerDiscard(false);
onClose();
}, 100);
};
const renderListView = (): JSX.Element => (
<div className="add-span-to-funnel-modal">
{!!filteredData?.length && (
<div className="add-span-to-funnel-modal__search">
<Input
className="add-span-to-funnel-modal__search-input"
placeholder="Search by name, description, or tags..."
prefix={<Search size={12} />}
value={searchQuery}
onChange={handleSearch}
/>
</div>
)}
<div className="add-span-to-funnel-modal__list">
<OverlayScrollbar>
<TracesFunnelsContentRenderer
isError={isError}
isLoading={isLoading || isFetching}
data={filteredData || []}
onCreateFunnel={handleCreateNewClick}
onFunnelClick={(funnel: FunnelData): void => handleFunnelClick(funnel)}
shouldRedirectToTracesListOnDeleteSuccess={false}
/>
</OverlayScrollbar>
</div>
<CreateFunnel
isOpen={isCreateModalOpen}
onClose={(funnelId): void => {
if (funnelId) {
setSelectedFunnelId(funnelId);
setActiveView(ModalView.DETAILS);
setIsCreatedFromSpan(true);
}
setIsCreateModalOpen(false);
}}
redirectToDetails={false}
/>
</div>
);
const renderDetailsView = ({ span }: { span: Span }): JSX.Element => (
<div className="add-span-to-funnel-modal add-span-to-funnel-modal--details">
<Button
type="text"
className="add-span-to-funnel-modal__back-button"
onClick={handleBack}
>
<ArrowLeft size={14} />
All funnels
</Button>
<div className="traces-funnel-details">
<div className="traces-funnel-details__steps-config">
<Spin
className="add-span-to-funnel-modal__loading-spinner"
spinning={isFunnelDetailsLoading || isFunnelDetailsFetching}
indicator={<LoadingOutlined spin />}
>
{selectedFunnelId && funnelDetails?.payload && (
<FunnelProvider
funnelId={selectedFunnelId}
hasSingleStep={isCreatedFromSpan}
>
<FunnelDetailsView
funnel={funnelDetails.payload}
span={span}
triggerAutoSave={triggerSave}
showNotifications
onChangesDetected={setIsUnsavedChanges}
triggerDiscard={triggerDiscard}
/>
</FunnelProvider>
)}
</Spin>
</div>
</div>
</div>
);
return (
<SignozModal
open={isOpen}
onCancel={onClose}
width={570}
title="Add span to funnel"
className={cx('add-span-to-funnel-modal-container', {
'add-span-to-funnel-modal-container--details':
activeView === ModalView.DETAILS,
})}
footer={
activeView === ModalView.DETAILS
? [
<Button
type="default"
key="discard"
onClick={handleDiscard}
className="add-span-to-funnel-modal__discard-button"
disabled={!isUnsavedChanges}
>
Discard
</Button>,
<Button
key="save"
type="primary"
className="add-span-to-funnel-modal__save-button"
onClick={handleSaveFunnel}
disabled={!isUnsavedChanges}
icon={<Check size={14} color="var(--bg-vanilla-100)" />}
>
Save Funnel
</Button>,
]
: [
<Button
key="create"
type="default"
className="add-span-to-funnel-modal__create-button"
onClick={handleCreateNewClick}
icon={<Plus size={14} />}
>
Create new funnel
</Button>,
]
}
>
{activeView === ModalView.LIST
? renderListView()
: renderDetailsView({ span })}
</SignozModal>
);
}
export default AddSpanToFunnelModal;

View File

@@ -0,0 +1,39 @@
.span-line-action-buttons {
display: flex;
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
right: 0;
cursor: pointer;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-400);
.ant-btn-default {
border: none;
box-shadow: none;
padding: 9px;
justify-content: center;
align-items: center;
display: flex;
&.active-tab {
background-color: var(--bg-slate-400);
}
}
.copy-span-btn {
border-color: var(--bg-slate-400) !important;
}
}
.lightMode {
.span-line-action-buttons {
border: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-400);
.copy-span-btn {
border-color: var(--bg-vanilla-400) !important;
}
}
}

View File

@@ -0,0 +1,134 @@
import { fireEvent, screen } from '@testing-library/react';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { render } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import SpanLineActionButtons from '../index';
// Mock the useCopySpanLink hook
jest.mock('hooks/trace/useCopySpanLink');
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1000,
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
describe('SpanLineActionButtons', () => {
beforeEach(() => {
// Clear mock before each test
jest.clearAllMocks();
});
it('renders copy link button with correct icon', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the button is rendered
const copyButton = screen.getByRole('button');
expect(copyButton).toBeInTheDocument();
// Check if the link icon is rendered
const linkIcon = screen.getByRole('img', { hidden: true });
expect(linkIcon).toHaveClass('anticon anticon-link');
});
it('calls onSpanCopy when copy button is clicked', () => {
const mockOnSpanCopy = jest.fn();
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: mockOnSpanCopy,
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called
expect(mockOnSpanCopy).toHaveBeenCalledTimes(1);
});
it('applies correct styling classes', () => {
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: jest.fn(),
});
render(<SpanLineActionButtons span={mockSpan} />);
// Check if the main container has the correct class
const container = screen
.getByRole('button')
.closest('.span-line-action-buttons');
expect(container).toHaveClass('span-line-action-buttons');
// Check if the button has the correct class
const copyButton = screen.getByRole('button');
expect(copyButton).toHaveClass('copy-span-btn');
});
it('copies span link to clipboard when copy button is clicked', () => {
const mockSetCopy = jest.fn();
const mockUrlQuery = {
delete: jest.fn(),
set: jest.fn(),
toString: jest.fn().mockReturnValue('spanId=test-span-id'),
};
const mockPathname = '/test-path';
const mockLocation = {
origin: 'http://localhost:3000',
};
// Mock window.location
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
// Mock useCopySpanLink hook
(useCopySpanLink as jest.Mock).mockReturnValue({
onSpanCopy: (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
mockUrlQuery.delete('spanId');
mockUrlQuery.set('spanId', mockSpan.spanId);
const link = `${
window.location.origin
}${mockPathname}?${mockUrlQuery.toString()}`;
mockSetCopy(link);
},
});
render(<SpanLineActionButtons span={mockSpan} />);
// Click the copy button
const copyButton = screen.getByRole('button');
fireEvent.click(copyButton);
// Verify the copy function was called with correct link
expect(mockSetCopy).toHaveBeenCalledWith(
'http://localhost:3000/test-path?spanId=test-span-id',
);
});
});

View File

@@ -0,0 +1,28 @@
import { LinkOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { Span } from 'types/api/trace/getTraceV2';
import './SpanLineActionButtons.styles.scss';
export interface SpanLineActionButtonsProps {
span: Span;
}
export default function SpanLineActionButtons({
span,
}: SpanLineActionButtonsProps): JSX.Element {
const { onSpanCopy } = useCopySpanLink(span);
return (
<div className="span-line-action-buttons">
<Tooltip title="Copy Span Link">
<Button
size="small"
icon={<LinkOutlined size={14} />}
onClick={onSpanCopy}
className="copy-span-btn"
/>
</Tooltip>
</div>
);
}

View File

@@ -0,0 +1,11 @@
.trace-waterfall {
height: 100%;
display: flex;
flex-direction: column;
.loading-skeleton {
justify-content: center;
align-items: center;
padding: 20px;
}
}

View File

@@ -0,0 +1,136 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { Skeleton } from 'antd';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV2SuccessResponse, Span } from 'types/api/trace/getTraceV2';
import { TraceWaterfallStates } from './constants';
import Error from './TraceWaterfallStates/Error/Error';
import NoData from './TraceWaterfallStates/NoData/NoData';
import Success from './TraceWaterfallStates/Success/Success';
import './TraceWaterfall.styles.scss';
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
}
interface ITraceWaterfallProps {
traceId: string;
uncollapsedNodes: string[];
traceData:
| SuccessResponse<GetTraceV2SuccessResponse, unknown>
| ErrorResponse
| undefined;
isFetchingTraceData: boolean;
errorFetchingTraceData: unknown;
interestedSpanId: IInterestedSpan;
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
}
function TraceWaterfall(props: ITraceWaterfallProps): JSX.Element {
const {
traceData,
isFetchingTraceData,
errorFetchingTraceData,
interestedSpanId,
traceId,
uncollapsedNodes,
setInterestedSpanId,
setSelectedSpan,
selectedSpan,
} = props;
// get the current state of trace waterfall based on the API lifecycle
const traceWaterfallState = useMemo(() => {
if (isFetchingTraceData) {
if (
traceData &&
traceData.payload &&
traceData.payload.spans &&
traceData.payload.spans.length > 0
) {
return TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT;
}
return TraceWaterfallStates.LOADING;
}
if (errorFetchingTraceData) {
return TraceWaterfallStates.ERROR;
}
if (
traceData &&
traceData.payload &&
traceData.payload.spans &&
traceData.payload.spans.length === 0
) {
return TraceWaterfallStates.NO_DATA;
}
return TraceWaterfallStates.SUCCESS;
}, [errorFetchingTraceData, isFetchingTraceData, traceData]);
// capture the spans from the response, since we do not need to do any manipulation on the same we will keep this as a simple constant [ memoized ]
const spans = useMemo(() => traceData?.payload?.spans || [], [
traceData?.payload?.spans,
]);
// get the content based on the current state of the trace waterfall
const getContent = useMemo(() => {
switch (traceWaterfallState) {
case TraceWaterfallStates.LOADING:
return (
<div className="loading-skeleton">
<Skeleton active paragraph={{ rows: 6 }} />
</div>
);
case TraceWaterfallStates.ERROR:
return <Error error={errorFetchingTraceData as AxiosError} />;
case TraceWaterfallStates.NO_DATA:
return <NoData id={traceId} />;
case TraceWaterfallStates.SUCCESS:
case TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT:
return (
<Success
spans={spans}
traceMetadata={{
traceId,
startTime: traceData?.payload?.startTimestampMillis || 0,
endTime: traceData?.payload?.endTimestampMillis || 0,
hasMissingSpans: traceData?.payload?.hasMissingSpans || false,
}}
interestedSpanId={interestedSpanId || ''}
uncollapsedNodes={uncollapsedNodes}
setInterestedSpanId={setInterestedSpanId}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
isFetching={
traceWaterfallState ===
TraceWaterfallStates.FETCHING_WITH_OLD_DATA_PRESENT
}
/>
);
default:
return <Spinner tip="Fetching the trace!" />;
}
}, [
errorFetchingTraceData,
interestedSpanId,
selectedSpan,
setInterestedSpanId,
setSelectedSpan,
spans,
traceData?.payload?.endTimestampMillis,
traceData?.payload?.hasMissingSpans,
traceData?.payload?.startTimestampMillis,
traceId,
traceWaterfallState,
uncollapsedNodes,
]);
return <div className="trace-waterfall">{getContent}</div>;
}
export default TraceWaterfall;

View File

@@ -0,0 +1,30 @@
.error-waterfall {
display: flex;
padding: 12px;
margin: 20px;
gap: 12px;
align-items: flex-start;
border-radius: 4px;
background: var(--bg-cherry-500);
.text {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
flex-shrink: 0;
}
.value {
color: var(--bg-vanilla-100);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}

View File

@@ -0,0 +1,25 @@
import { Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import './Error.styles.scss';
interface IErrorProps {
error: AxiosError;
}
function Error(props: IErrorProps): JSX.Element {
const { error } = props;
return (
<div className="error-waterfall">
<Typography.Text className="text">Something went wrong!</Typography.Text>
<Tooltip title={error?.message}>
<Typography.Text className="value" ellipsis>
{error?.message}
</Typography.Text>
</Tooltip>
</div>
);
}
export default Error;

View File

@@ -0,0 +1,12 @@
import { Typography } from 'antd';
interface INoDataProps {
id: string;
}
function NoData(props: INoDataProps): JSX.Element {
const { id } = props;
return <Typography.Text>No Trace found with the id: {id} </Typography.Text>;
}
export default NoData;

View File

@@ -0,0 +1,60 @@
.filter-row {
display: flex;
align-items: center;
padding: 16px 20px 0px 20px;
gap: 12px;
.query-builder-search-v2 {
width: 100%;
}
.pre-next-toggle {
display: flex;
flex-shrink: 0;
gap: 12px;
.ant-typography {
display: flex;
align-items: center;
color: var(--bg-vanilla-400);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
.ant-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
}
.no-results {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--bg-vanilla-400);
font-family: 'Geist Mono';
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}
.lightMode {
.filter-row {
.pre-next-toggle {
.ant-typography {
color: var(--bg-ink-400);
}
}
.no-results {
color: var(--bg-ink-400);
}
}
}

View File

@@ -0,0 +1,207 @@
import { useCallback, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { InfoCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Spin, Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { BASE_FILTER_QUERY } from './constants';
import './Filters.styles.scss';
function prepareQuery(filters: TagFilter, traceID: string): Query {
return {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
aggregateOperator: TracesAggregatorOperator.NOOP,
orderBy: [{ columnName: 'timestamp', order: 'asc' }],
filters: {
...filters,
items: [
...filters.items,
{
id: '5ab8e1cf',
key: {
key: 'trace_id',
dataType: DataTypes.String,
type: '',
id: 'trace_id--string----true',
},
op: '=',
value: traceID,
},
],
},
},
],
},
};
}
function Filters({
startTime,
endTime,
traceID,
onFilteredSpansChange = (): void => {},
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
}): JSX.Element {
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
const { search } = useLocation();
const history = useHistory();
const handlePrevNext = useCallback(
(index: number, spanId?: string): void => {
const searchParams = new URLSearchParams(search);
if (spanId) {
searchParams.set('spanId', spanId);
} else {
searchParams.set('spanId', filteredSpanIds[index]);
}
history.replace({ search: searchParams.toString() });
},
[filteredSpanIds, history, search],
);
const { isFetching, error } = useGetQueryRange(
{
query: prepareQuery(filters, traceID),
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start: startTime,
end: endTime,
params: {
dataSource: 'traces',
},
tableParams: {
pagination: {
offset: 0,
limit: 200,
},
selectColumns: [
{
key: 'name',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
isIndexed: false,
},
],
},
},
DEFAULT_ENTITY_VERSION,
{
queryKey: [filters],
enabled: filters.items.length > 0,
onSuccess: (data) => {
const isFilterActive = filters.items.length > 0;
if (data?.payload.data.newResult.data.result[0].list) {
const uniqueSpans = uniqBy(
data?.payload.data.newResult.data.result[0].list,
'data.spanID',
);
const spanIds = uniqueSpans.map((val) => val.data.spanID);
setFilteredSpanIds(spanIds);
onFilteredSpansChange?.(spanIds, isFilterActive);
handlePrevNext(0, spanIds[0]);
setNoData(false);
} else {
setNoData(true);
setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0);
}
},
},
);
return (
<div className="filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
/>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
icon={<ChevronUp size={14} />}
disabled={currentSearchedIndex === 0}
type="text"
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
/>
<Button
icon={<ChevronDown size={14} />}
type="text"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
/>
</div>
)}
{isFetching && <Spin indicator={<LoadingOutlined spin />} size="small" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<InfoCircleOutlined size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
</div>
);
}
Filters.defaultProps = {
onFilteredSpansChange: undefined,
};
export default Filters;

View File

@@ -0,0 +1,38 @@
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
export const BASE_FILTER_QUERY: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.EMPTY,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters: {
items: [],
op: 'AND',
},
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: 200,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset: 0,
selectColumns: [],
};

View File

@@ -0,0 +1,553 @@
.waterfall-loading-bar {
height: 2px;
background: var(--primary);
animation: waterfall-loading 1.5s ease-in-out infinite;
flex-shrink: 0;
}
@keyframes waterfall-loading {
0% {
opacity: 0.3;
transform: scaleX(0.3);
transform-origin: left;
}
50% {
opacity: 1;
transform: scaleX(1);
transform-origin: left;
}
100% {
opacity: 0.3;
transform: scaleX(0.3);
transform-origin: right;
}
}
.success-content {
overflow-y: hidden;
overflow-x: hidden;
max-width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.missing-spans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: rgba(69, 104, 220, 0.1);
.left-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
}
.right-info {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row-reverse;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.right-info:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.waterfall-split-panel {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0px 20px 0px 20px;
&::-webkit-scrollbar {
width: 0.1rem;
}
}
.waterfall-split-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
height: 25px;
background-color: var(--bg-ink-500);
.sidebar-header {
flex-shrink: 0;
}
.resize-handle-header {
width: 4px;
flex-shrink: 0;
}
.timeline-header {
flex: 1;
overflow: hidden;
padding: 0 15px;
}
}
.waterfall-split-body {
display: flex;
position: relative;
}
.waterfall-sidebar {
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
border-right: 1px solid var(--bg-slate-400);
.resizable-box__content {
overflow-x: auto;
overflow-y: hidden;
&::-webkit-scrollbar {
height: 0;
}
scrollbar-width: none;
}
&::-webkit-scrollbar {
height: 0.3rem;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-400);
border-radius: 4px;
}
}
.span-tree-table {
position: relative;
border-collapse: collapse;
.span-tree-row {
display: flex;
align-items: center;
}
.span-tree-cell {
display: flex;
width: 100%;
height: 28px;
align-items: center;
overflow: visible;
padding: 0;
}
.span-tree-row:hover,
.span-tree-row.hovered-span {
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
.span-overview {
background: unset !important;
}
}
}
.sidebar-resize-handle {
width: 4px;
flex-shrink: 0;
cursor: col-resize;
user-select: none;
touch-action: none;
background: transparent;
transition: background 0.15s ease;
z-index: 1;
&:hover,
&:active {
background: rgba(35, 196, 248, 0.2);
}
}
.waterfall-timeline {
flex: 1;
position: relative;
overflow: hidden;
.timeline-row {
display: flex;
align-items: center;
}
.timeline-row:hover,
.timeline-row.hovered-span {
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
}
}
// Shared span component styles (used in both panels)
.span-overview {
display: flex;
align-items: center;
flex-shrink: 0;
height: 100%;
width: 100%;
cursor: pointer;
position: relative;
white-space: nowrap;
.tree-indent {
display: inline-block;
flex-shrink: 0;
}
.tree-line {
position: absolute;
background-color: var(--bg-slate-400);
pointer-events: none;
}
.tree-connector {
position: absolute;
width: 11px;
height: 50%;
border-left: 1px solid var(--bg-slate-400);
border-bottom: 1px solid var(--bg-slate-400);
border-bottom-left-radius: 6px;
pointer-events: none;
}
.tree-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
color: var(--bg-vanilla-400);
&.no-children {
cursor: default;
width: 18px;
height: 18px;
}
}
.tree-icon {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
margin: 0 6px;
&.is-error {
box-shadow: 0 0 0 2px rgba(255, 70, 70, 0.3);
}
}
.tree-label {
color: #fff;
font-family: 'Inter';
font-size: 13px;
font-weight: 400;
line-height: 28px;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.span-row-actions {
position: sticky;
right: 0;
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
padding-right: 4px;
padding-left: 8px;
flex-shrink: 0;
height: 100%;
background: linear-gradient(to left, var(--bg-ink-500) 60%, transparent);
z-index: 2;
opacity: 0;
pointer-events: none;
.span-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 2px;
cursor: pointer;
color: var(--bg-vanilla-400);
background: transparent;
border: none;
padding: 0;
&:hover {
background: var(--bg-slate-400);
}
}
}
&:hover .span-row-actions {
opacity: 1;
pointer-events: auto;
}
}
// Also show action buttons when hovering the tree row (parent of span-overview)
.span-tree-row:hover .span-row-actions,
.span-tree-row.hovered-span .span-row-actions {
opacity: 1;
pointer-events: auto;
}
.span-duration {
display: flex;
align-items: center;
height: 28px;
position: relative;
width: 100%;
padding: 0 15px;
cursor: pointer;
.span-bar {
position: absolute;
height: 18px;
top: 5px;
border-radius: 2px;
display: flex;
align-items: center;
padding: 0 8px;
overflow: hidden;
cursor: pointer;
white-space: nowrap;
color: rgba(0, 0, 0, 0.9);
background-color: var(--span-color);
border: 1px solid transparent;
.span-info {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
overflow: hidden;
z-index: 1;
.span-name {
font-weight: 500;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
min-width: 0;
}
.span-duration-text {
color: inherit;
opacity: 0.8;
font-size: 10px;
margin-left: 8px;
flex-shrink: 0;
}
}
}
.event-dot {
position: absolute;
top: 50%;
transform: translate(-50%, -50%) rotate(45deg);
width: 5px;
height: 5px;
background-color: var(--event-dot-bg, var(--bg-robin-500));
border: 1px solid var(--event-dot-border, var(--bg-robin-600));
cursor: pointer;
z-index: 1;
&.error {
background-color: var(--bg-cherry-500);
border-color: var(--bg-cherry-600);
}
&:hover {
transform: translate(-50%, -50%) rotate(45deg) scale(1.5);
}
}
}
// Hover state: static stripe pattern + border
.timeline-row:hover .span-bar,
.timeline-row.hovered-span .span-bar {
color: var(--span-color);
background-color: rgba(var(--span-color-rgb), 0.1);
border: 1px solid rgba(var(--span-color-rgb), 0.2);
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(var(--span-color-rgb), 0.04) 10px,
rgba(var(--span-color-rgb), 0.04) 20px
);
}
// Selected state: stripe pattern + dashed border
.interested-span .span-bar,
.selected-non-matching-span .span-bar {
color: var(--span-color);
background-color: rgba(var(--span-color-rgb), 0.1);
border: 1px dashed var(--span-color);
background-image: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(var(--span-color-rgb), 0.04) 10px,
rgba(var(--span-color-rgb), 0.04) 20px
);
}
// Shared state classes for both panels
.interested-span,
.selected-non-matching-span {
border-radius: 4px;
background: rgba(171, 189, 255, 0.06) !important;
}
.dimmed-span {
opacity: 0.4;
}
.highlighted-span {
opacity: 1;
}
.selected-non-matching-span {
.tree-label {
opacity: 0.5;
}
}
}
.span-dets {
.related-logs {
display: flex;
width: 160px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 8px;
border-radius: 2px;
border: 1px solid var(--Slate-400, #1d212d);
background: var(--Slate-500, #161922);
box-shadow: none;
}
}
.lightMode {
.success-content {
.span-overview {
.tree-arrow {
color: var(--bg-ink-400);
}
.tree-label {
color: var(--bg-ink-400);
}
.tree-line {
background-color: var(--bg-vanilla-400);
}
.tree-connector {
border-color: var(--bg-vanilla-400);
}
.span-row-actions {
background: linear-gradient(
to left,
var(--bg-vanilla-100) 60%,
transparent
);
.span-action-btn {
color: var(--bg-ink-400);
&:hover {
background: var(--bg-vanilla-300);
}
}
}
}
.interested-span {
border-radius: 4px;
background: var(--bg-vanilla-300);
}
.span-duration .span-bar {
color: rgba(255, 255, 255, 0.9);
}
// Light mode hover/selected: span color must override the white default above
.timeline-row:hover .span-bar,
.timeline-row.hovered-span .span-bar {
color: var(--span-color);
}
.interested-span .span-bar,
.selected-non-matching-span .span-bar {
color: var(--span-color);
}
.waterfall-sidebar {
border-right: 1px solid var(--bg-vanilla-300);
}
.waterfall-split-header {
background-color: var(--bg-vanilla-200) !important;
}
}
.span-dets {
.related-logs {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-300);
}
}
}

View File

@@ -0,0 +1,693 @@
import {
Dispatch,
memo,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import { Button, Popover, Tooltip, Typography } from 'antd';
import cx from 'classnames';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb, generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
AlertCircle,
ArrowUpRight,
ChevronDown,
ChevronRight,
Link,
ListPlus,
} from 'lucide-react';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { Span } from 'types/api/trace/getTraceV2';
import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import SpanHoverCard from '../../../SpanHoverCard/SpanHoverCard';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../TraceWaterfall';
import Filters from './Filters/Filters';
import './Success.styles.scss';
// css config
const CONNECTOR_WIDTH = 20;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface ITraceMetadata {
traceId: string;
startTime: number;
endTime: number;
hasMissingSpans: boolean;
}
interface ISuccessProps {
spans: Span[];
traceMetadata: ITraceMetadata;
interestedSpanId: IInterestedSpan;
uncollapsedNodes: string[];
setInterestedSpanId: Dispatch<SetStateAction<IInterestedSpan>>;
selectedSpan: Span | undefined;
setSelectedSpan: Dispatch<SetStateAction<Span | undefined>>;
isFetching?: boolean;
}
const SpanOverview = memo(function SpanOverview({
span,
isSpanCollapsed,
handleCollapseUncollapse,
handleSpanClick,
selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata,
onAddSpanToFunnel,
}: {
span: Span;
isSpanCollapsed: boolean;
handleCollapseUncollapse: (id: string, collapse: boolean) => void;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata;
onAddSpanToFunnel: (span: Span) => void;
}): JSX.Element {
const isRootSpan = span.level === 0;
const { onSpanCopy } = useCopySpanLink(span);
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = `var(--bg-cherry-500)`;
}
// Smart highlighting logic
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
const indentWidth = isRootSpan ? 0 : span.level * CONNECTOR_WIDTH;
const handleFunnelClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
onAddSpanToFunnel(span);
};
return (
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
{!isRootSpan &&
Array.from({ length: span.level }, (_, i) => {
const lvl = i + 1;
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
if (lvl < span.level) {
// Stop the line at 50% for the last child's parent level
const isLastChildParentLine = !span.hasSibling && lvl === span.level - 1;
return (
<div
key={lvl}
className="tree-line"
style={{
left: xPos,
top: 0,
width: 1,
height: isLastChildParentLine ? '50%' : '100%',
}}
/>
);
}
return (
<div key={lvl}>
<div
className="tree-line"
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
/>
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
</div>
);
})}
{/* Indent spacer */}
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
{/* Expand/collapse arrow or leaf bullet */}
{span.hasChildren ? (
<span
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.spanId, !isSpanCollapsed);
}}
>
{isSpanCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
) : (
<span className="tree-arrow no-children" />
)}
{/* Colored service dot */}
<span
className={cx('tree-icon', { 'is-error': span.hasError })}
style={{ backgroundColor: color }}
/>
{/* Span name */}
<span className="tree-label">{span.name}</span>
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<Tooltip title="Copy Span Link">
<button type="button" className="span-action-btn" onClick={onSpanCopy}>
<Link size={12} />
</button>
</Tooltip>
<Tooltip title="Add to Trace Funnel">
<button
type="button"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</button>
</Tooltip>
</span>
</div>
</SpanHoverCard>
);
});
export const SpanDuration = memo(function SpanDuration({
span,
traceMetadata,
handleSpanClick,
selectedSpan,
filteredSpanIds,
isFilterActive,
}: {
span: Span;
traceMetadata: ITraceMetadata;
selectedSpan: Span | undefined;
handleSpanClick: (span: Span) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
}): JSX.Element {
const { time, timeUnitName } = convertTimeToRelevantUnit(
span.durationNano / 1e6,
);
const spread = traceMetadata.endTime - traceMetadata.startTime;
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.durationNano * 1e2) / (spread * 1e6);
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
let rgbColor = colorToRgb(color);
if (span.hasError) {
color = `var(--bg-cherry-500)`;
rgbColor = '239, 68, 68';
}
const isMatching =
isFilterActive && (filteredSpanIds || []).includes(span.spanId);
const isSelected = selectedSpan?.spanId === span.spanId;
const isDimmed = isFilterActive && !isMatching && !isSelected;
const isHighlighted = isFilterActive && isMatching && !isSelected;
const isSelectedNonMatching = isSelected && isFilterActive && !isMatching;
return (
<div
className={cx('span-duration', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className="span-bar"
style={
{
left: `${leftOffset}%`,
width: `${width}%`,
'--span-color': color,
'--span-color-rgb': rgbColor,
} as React.CSSProperties
}
>
<span className="span-info">
<span className="span-name">{span.name}</span>
<span className="span-duration-text">{`${toFixed(
time,
2,
)} ${timeUnitName}`}</span>
</span>
</div>
</SpanHoverCard>
{span.event?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const spanDurationMs = span.durationNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = Math.max(1, Math.min(eventOffsetPercent, 99));
const { isError } = event;
// Position relative to the span bar: leftOffset% + clampedOffset% of width%
const dotLeft = leftOffset + (clampedOffset / 100) * width;
const parts = rgbColor.split(', ');
const dotBg = `rgb(${parts
.map((c) => Math.round(Number(c) * 0.7))
.join(', ')})`;
const dotBorder = `rgb(${parts
.map((c) => Math.round(Number(c) * 0.5))
.join(', ')})`;
return (
<Popover
key={`${span.spanId}-event-${event.name}-${event.timeUnixNano}`}
content={
<EventTooltipContent
eventName={event.name}
timeOffsetMs={eventTimeMs - span.timestamp}
isError={isError}
attributeMap={event.attributeMap || {}}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
>
<div
className={`event-dot ${isError ? 'error' : ''}`}
style={
{
left: `${dotLeft}%`,
'--event-dot-bg': isError ? undefined : dotBg,
'--event-dot-border': isError ? undefined : dotBorder,
} as React.CSSProperties
}
/>
</Popover>
);
})}
</div>
);
});
// table config
const columnDefHelper = createColumnHelper<Span>();
const ROW_HEIGHT = 28;
const DEFAULT_SIDEBAR_WIDTH = 450;
const MIN_SIDEBAR_WIDTH = 240;
const MAX_SIDEBAR_WIDTH = 900;
const BASE_CONTENT_WIDTH = 300;
function Success(props: ISuccessProps): JSX.Element {
const {
spans,
traceMetadata,
interestedSpanId,
uncollapsedNodes,
setInterestedSpanId,
setSelectedSpan,
selectedSpan,
isFetching,
} = props;
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [isFilterActive, setIsFilterActive] = useState<boolean>(false);
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>();
const prevHoveredSpanIdRef = useRef<string | null>(null);
// Imperative DOM class toggling for hover highlights (avoids React re-renders)
const applyHoverClass = useCallback((spanId: string | null): void => {
const prev = prevHoveredSpanIdRef.current;
if (prev === spanId) {
return;
}
if (prev) {
const prevElements = document.querySelectorAll(`[data-span-id="${prev}"]`);
prevElements.forEach((el) => el.classList.remove('hovered-span'));
}
if (spanId) {
const nextElements = document.querySelectorAll(`[data-span-id="${spanId}"]`);
nextElements.forEach((el) => el.classList.add('hovered-span'));
}
prevHoveredSpanIdRef.current = spanId;
}, []);
const handleRowMouseEnter = useCallback(
(spanId: string): void => {
applyHoverClass(spanId);
},
[applyHoverClass],
);
const handleRowMouseLeave = useCallback((): void => {
applyHoverClass(null);
}, [applyHoverClass]);
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean) => {
setFilteredSpanIds(spanIds);
setIsFilterActive(isActive);
},
[],
);
const handleCollapseUncollapse = useCallback(
(spanId: string, collapse: boolean) => {
setInterestedSpanId({ spanId, isUncollapsed: !collapse });
},
[setInterestedSpanId],
);
const handleVirtualizerInstanceChanged = useCallback(
(instance: Virtualizer<HTMLDivElement, Element>): void => {
const { range } = instance;
// when there are less than 500 elements in the API call that means there is nothing to fetch on top and bottom so
// do not trigger the API call
if (spans.length < 500) {
return;
}
if (range?.startIndex === 0 && instance.isScrolling) {
// do not trigger for trace root as nothing to fetch above
if (spans[0].level !== 0) {
setInterestedSpanId({
spanId: spans[0].spanId,
isUncollapsed: false,
});
}
return;
}
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
setInterestedSpanId({
spanId: spans[spans.length - 1].spanId,
isUncollapsed: false,
});
}
},
[spans, setInterestedSpanId],
);
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] = useState(
false,
);
const [selectedSpanToAddToFunnel, setSelectedSpanToAddToFunnel] = useState<
Span | undefined
>(undefined);
const handleAddSpanToFunnel = useCallback((span: Span): void => {
setIsAddSpanToFunnelModalOpen(true);
setSelectedSpanToAddToFunnel(span);
}, []);
const urlQuery = useUrlQuery();
const { safeNavigate } = useSafeNavigate();
const handleSpanClick = useCallback(
(span: Span): void => {
setSelectedSpan(span);
if (span?.spanId) {
urlQuery.set('spanId', span?.spanId);
}
safeNavigate({ search: urlQuery.toString() });
},
[setSelectedSpan, urlQuery, safeNavigate],
);
// Left side columns using TanStack React Table (extensible for future columns)
const leftColumns = useMemo(
() => [
columnDefHelper.display({
id: 'span-name',
header: '',
cell: (cellProps): JSX.Element => (
<SpanOverview
span={cellProps.row.original}
handleCollapseUncollapse={handleCollapseUncollapse}
isSpanCollapsed={
!uncollapsedNodes.includes(cellProps.row.original.spanId)
}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
onAddSpanToFunnel={handleAddSpanToFunnel}
/>
),
}),
],
[
handleCollapseUncollapse,
uncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
filteredSpanIds,
isFilterActive,
handleAddSpanToFunnel,
],
);
const leftTable = useReactTable({
data: spans,
columns: leftColumns,
getCoreRowModel: getCoreRowModel(),
});
// Shared virtualizer - one instance drives both panels
const virtualizer = useVirtualizer({
count: spans.length,
getScrollElement: (): HTMLDivElement | null => scrollContainerRef.current,
estimateSize: (): number => ROW_HEIGHT,
overscan: 20,
onChange: handleVirtualizerInstanceChanged,
});
useEffect(() => {
virtualizerRef.current = virtualizer;
}, [virtualizer]);
// Compute max content width for sidebar horizontal scroll
const maxContentWidth = useMemo(() => {
if (spans.length === 0) {
return sidebarWidth;
}
const maxLevel = spans.reduce((max, span) => Math.max(max, span.level), 0);
return Math.max(
sidebarWidth,
maxLevel * (CONNECTOR_WIDTH + VERTICAL_CONNECTOR_WIDTH) + BASE_CONTENT_WIDTH,
);
}, [spans, sidebarWidth]);
// Scroll to interested span
useEffect(() => {
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
const idx = spans.findIndex(
(span) => span.spanId === interestedSpanId.spanId,
);
if (idx !== -1) {
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
}, 400);
setSelectedSpan(spans[idx]);
}
} else {
setSelectedSpan((prev) => {
if (!prev) {
return spans[0];
}
return prev;
});
}
}, [interestedSpanId, setSelectedSpan, spans]);
const virtualItems = virtualizer.getVirtualItems();
const leftRows = leftTable.getRowModel().rows;
return (
<div className="success-content">
{traceMetadata.hasMissingSpans && (
<div className="missing-spans">
<section className="left-info">
<AlertCircle size={14} />
<Typography.Text className="text">
This trace has missing spans
</Typography.Text>
</section>
<Button
icon={<ArrowUpRight size={14} />}
className="right-info"
type="text"
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/userguide/traces/#missing-spans',
'_blank',
)
}
>
Learn More
</Button>
</div>
)}
<Filters
startTime={traceMetadata.startTime / 1e3}
endTime={traceMetadata.endTime / 1e3}
traceID={traceMetadata.traceId}
onFilteredSpansChange={handleFilteredSpansChange}
/>
{isFetching && <div className="waterfall-loading-bar" />}
<div className="waterfall-split-panel" ref={scrollContainerRef}>
{/* Sticky header row */}
<div className="waterfall-split-header">
<div
className="sidebar-header"
style={{ width: sidebarWidth, flexShrink: 0 }}
/>
<div className="resize-handle-header" />
<div className="timeline-header">
<TimelineV3
startTimestamp={traceMetadata.startTime}
endTimestamp={traceMetadata.endTime}
timelineHeight={10}
offsetTimestamp={0}
/>
</div>
</div>
{/* Split body */}
<div
className="waterfall-split-body"
style={{
minHeight: virtualizer.getTotalSize(),
height: '100%',
}}
>
{/* Left panel - table with horizontal scroll */}
<ResizableBox
direction="horizontal"
defaultWidth={DEFAULT_SIDEBAR_WIDTH}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}
onResize={setSidebarWidth}
className="waterfall-sidebar"
>
<table className="span-tree-table" style={{ width: maxContentWidth }}>
<tbody>
{virtualItems.map((virtualRow) => {
const row = leftRows[virtualRow.index];
const span = spans[virtualRow.index];
return (
<tr
key={String(virtualRow.key)}
data-testid={`cell-0-${span.spanId}`}
data-span-id={span.spanId}
className="span-tree-row"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => handleRowMouseEnter(span.spanId)}
onMouseLeave={handleRowMouseLeave}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="span-tree-cell">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</ResizableBox>
{/* Right panel - timeline bars */}
<div className="waterfall-timeline">
{virtualItems.map((virtualRow) => {
const span = spans[virtualRow.index];
return (
<div
key={String(virtualRow.key)}
data-testid={`cell-1-${span.spanId}`}
data-span-id={span.spanId}
className="timeline-row"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => handleRowMouseEnter(span.spanId)}
onMouseLeave={handleRowMouseLeave}
>
<SpanDuration
span={span}
traceMetadata={traceMetadata}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</div>
);
})}
</div>
</div>
</div>
{selectedSpanToAddToFunnel && (
<AddSpanToFunnelModal
span={selectedSpanToAddToFunnel}
isOpen={isAddSpanToFunnelModalOpen}
onClose={(): void => setIsAddSpanToFunnelModalOpen(false)}
/>
)}
</div>
);
}
export default Success;

View File

@@ -0,0 +1,268 @@
import useUrlQuery from 'hooks/useUrlQuery';
import { fireEvent, render, screen } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success';
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const HIGHLIGHTED_SPAN_CLASS = 'highlighted-span';
const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
jest.mock('components/TimelineV3/TimelineV3', () => ({
__esModule: true,
default: (): null => null,
}));
// Mock the hooks
jest.mock('hooks/useUrlQuery');
jest.mock('@signozhq/badge', () => ({
Badge: jest.fn(),
}));
const mockSpan: Span = {
spanId: 'test-span-id',
name: 'test-span',
serviceName: 'test-service',
durationNano: 1160000, // 1ms in nano
timestamp: 1234567890,
rootSpanId: 'test-root-span-id',
parentSpanId: 'test-parent-span-id',
traceId: 'test-trace-id',
hasError: false,
kind: 0,
references: [],
tagMap: {},
event: [],
rootName: 'test-root-name',
statusMessage: 'test-status-message',
statusCodeString: 'test-status-code-string',
spanKind: 'test-span-kind',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 0,
level: 0,
};
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1234567000,
endTime: 1234569000,
hasMissingSpans: false,
};
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
describe('SpanDuration', () => {
const mockSetSelectedSpan = jest.fn();
const mockUrlQuerySet = jest.fn();
const mockUrlQueryGet = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock URL query hook
(useUrlQuery as jest.Mock).mockReturnValue({
set: mockUrlQuerySet,
get: mockUrlQueryGet,
toString: () => 'spanId=test-span-id',
});
});
it('calls handleSpanClick when clicked', () => {
const mockHandleSpanClick = jest.fn();
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockHandleSpanClick}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
// Find and click the span duration element
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
fireEvent.click(spanElement);
// Verify handleSpanClick was called with the correct span
expect(mockHandleSpanClick).toHaveBeenCalledWith(mockSpan);
});
it('shows action buttons on hover', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen.getByText(SPAN_DURATION_TEXT);
// Initially, action buttons should not be visible
expect(screen.queryByRole('button')).not.toBeInTheDocument();
// Hover over the span
fireEvent.mouseEnter(spanElement);
// Action buttons should now be visible
expect(screen.getByRole('button')).toBeInTheDocument();
// Mouse leave should hide the buttons
fireEvent.mouseLeave(spanElement);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('applies interested-span class when span is selected', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies highlighted-span class when span matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
it('applies dimmed-span class when span does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={['other-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
});
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[mockSpan.spanId]}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={['different-span-id']}
isFilterActive
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('applies interested-span class when span is selected and no filter is active', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={mockSpan}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]}
isFilterActive={false}
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(SELECTED_NON_MATCHING_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(DIMMED_SPAN_CLASS);
});
it('dims span when filter is active but no matches found', () => {
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
selectedSpan={undefined}
handleSpanClick={mockSetSelectedSpan}
filteredSpanIds={[]} // Empty array but filter is active
isFilterActive // This is the key difference
/>,
);
const spanElement = screen
.getByText(SPAN_DURATION_TEXT)
.closest(SPAN_DURATION_CLASS);
expect(spanElement).toHaveClass(DIMMED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(HIGHLIGHTED_SPAN_CLASS);
expect(spanElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
});
});

View File

@@ -0,0 +1,419 @@
import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import Success from '../Success';
// Mock the required hooks with proper typing
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
(params: { search: string }) => void
>;
const mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/useUrlQuery', () => (): URLSearchParams => mockUrlQuery);
// App provider is already handled by test-utils
// React Router is already globally mocked
// Mock complex external dependencies that cause provider issues
jest.mock('components/SpanHoverCard/SpanHoverCard', () => {
function SpanHoverCard({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return <div>{children}</div>;
}
SpanHoverCard.displayName = 'SpanHoverCard';
return SpanHoverCard;
});
// Mock the Filters component that's causing React Query issues
jest.mock('../Filters/Filters', () => {
function Filters(): null {
return null;
}
Filters.displayName = 'Filters';
return Filters;
});
// Mock other potential dependencies
jest.mock(
'pages/TraceDetailsV3/TraceWaterfall/AddSpanToFunnelModal/AddSpanToFunnelModal',
() => {
function AddSpanToFunnelModal(): null {
return null;
}
AddSpanToFunnelModal.displayName = 'AddSpanToFunnelModal';
return AddSpanToFunnelModal;
},
);
jest.mock('pages/TraceDetailsV3/TraceWaterfall/SpanLineActionButtons', () => {
function SpanLineActionButtons(): null {
return null;
}
SpanLineActionButtons.displayName = 'SpanLineActionButtons';
return SpanLineActionButtons;
});
jest.mock('components/HttpStatusBadge/HttpStatusBadge', () => {
function HttpStatusBadge(): null {
return null;
}
HttpStatusBadge.displayName = 'HttpStatusBadge';
return HttpStatusBadge;
});
jest.mock('components/TimelineV3/TimelineV3', () => {
function TimelineV3(): null {
return null;
}
TimelineV3.displayName = 'TimelineV3';
return { __esModule: true, default: TimelineV3 };
});
// Mock other utilities that might cause issues
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
generateColor: (): string => '#1890ff',
}));
jest.mock('container/TraceDetail/utils', () => ({
convertTimeToRelevantUnit: (
value: number,
): { time: number; timeUnitName: string } => ({
time: value < 1000 ? value : value / 1000,
timeUnitName: value < 1000 ? 'ms' : 's',
}),
}));
jest.mock('utils/toFixed', () => ({
toFixed: (value: number, decimals: number): string => value.toFixed(decimals),
}));
// Mock useVirtualizer to render all items without actual virtualization
jest.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({
count,
}: {
count: number;
}): {
getVirtualItems: () => Array<{
index: number;
key: number;
start: number;
size: number;
}>;
getTotalSize: () => number;
scrollToIndex: jest.Mock;
} => ({
getVirtualItems: (): Array<{
index: number;
key: number;
start: number;
size: number;
}> =>
Array.from({ length: count }, (_, i) => ({
index: i,
key: i,
start: i * 54,
size: 54,
})),
getTotalSize: (): number => count * 54,
scrollToIndex: jest.fn(),
}),
Virtualizer: jest.fn(),
}));
const mockTraceMetadata = {
traceId: 'test-trace-id',
startTime: 1679748225000000,
endTime: 1679748226000000,
hasMissingSpans: false,
};
const createMockSpan = (spanId: string, level = 1): Span => ({
spanId,
traceId: 'test-trace-id',
rootSpanId: 'span-1',
parentSpanId: level === 0 ? '' : 'span-1',
name: `Test Span ${spanId}`,
serviceName: 'test-service',
timestamp: mockTraceMetadata.startTime + level * 100000,
durationNano: 50000000,
level,
hasError: false,
kind: 1,
references: [],
tagMap: {},
event: [],
rootName: 'Test Root Span',
statusMessage: '',
statusCodeString: 'OK',
spanKind: 'server',
hasChildren: false,
hasSibling: false,
subTreeNodeCount: 1,
});
const mockSpans = [
createMockSpan('span-1', 0),
createMockSpan('span-2', 1),
createMockSpan('span-3', 1),
];
// Shared TestComponent for all tests
function TestComponent(): JSX.Element {
const [selectedSpan, setSelectedSpan] = React.useState<Span | undefined>(
undefined,
);
return (
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
selectedSpan={selectedSpan}
setSelectedSpan={setSelectedSpan}
/>
);
}
describe('Span Click User Flows', () => {
const FIRST_SPAN_TEST_ID = 'cell-0-span-1';
const FIRST_SPAN_DURATION_TEST_ID = 'cell-1-span-1';
const SECOND_SPAN_TEST_ID = 'cell-0-span-2';
const SPAN_OVERVIEW_CLASS = '.span-overview';
const SPAN_DURATION_CLASS = '.span-duration';
const INTERESTED_SPAN_CLASS = 'interested-span';
const SECOND_SPAN_DURATION_TEST_ID = 'cell-1-span-2';
beforeEach(() => {
jest.clearAllMocks();
// Clear all URL parameters
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
});
it('clicking span updates URL with spanId parameter', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
/>,
undefined,
{ initialRoute: '/trace' },
);
// Initially URL should not have spanId
expect(mockUrlQuery.get('spanId')).toBeNull();
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
// Verify URL was updated with spanId
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringContaining('spanId=span-1'),
});
});
it('clicking span duration visually selects the span', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click on span-2 to test selection change
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
await user.click(span2DurationElement);
// Wait for the state update and re-render
await waitFor(() => {
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('both click areas produce the same visual result', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanOverviewElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
// Initially both areas should show the same visual selection (first span is auto-selected)
expect(spanOverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click span-2 to test selection change
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanDuration = screen.getByTestId(FIRST_SPAN_DURATION_TEST_ID);
const spanOverviewElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const spanDurationElement = spanDuration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
// Now span-2 should be selected, span-1 should not
expect(spanOverviewElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(spanDurationElement).not.toHaveClass(INTERESTED_SPAN_CLASS);
// Check that span-2 is selected
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Duration = screen.getByTestId(SECOND_SPAN_DURATION_TEST_ID);
const span2OverviewElement = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2DurationElement = span2Duration.querySelector(
SPAN_DURATION_CLASS,
) as HTMLElement;
expect(span2OverviewElement).toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2DurationElement).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('clicking different spans updates selection correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
// Wait for initial render and selection
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
expect(span1Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
// Click second span
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(span2Element);
// Wait for the state update and re-render
await waitFor(() => {
const span1Overview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const span1Element = span1Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
const span2Overview = screen.getByTestId(SECOND_SPAN_TEST_ID);
const span2Element = span2Overview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
// Second span should be selected, first should not
expect(span1Element).not.toHaveClass(INTERESTED_SPAN_CLASS);
expect(span2Element).toHaveClass(INTERESTED_SPAN_CLASS);
});
});
it('preserves existing URL parameters when selecting spans', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Pre-populate URL with existing parameters
mockUrlQuery.set('existingParam', 'existingValue');
mockUrlQuery.set('anotherParam', 'anotherValue');
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
interestedSpanId={{ spanId: '', isUncollapsed: false }}
uncollapsedNodes={mockSpans.map((s) => s.spanId)}
setInterestedSpanId={jest.fn()}
selectedSpan={undefined}
setSelectedSpan={jest.fn()}
/>,
undefined,
{ initialRoute: '/trace' },
);
// Click on the actual span element (not the wrapper)
const spanOverview = screen.getByTestId(FIRST_SPAN_TEST_ID);
const spanElement = spanOverview.querySelector(
SPAN_OVERVIEW_CLASS,
) as HTMLElement;
await user.click(spanElement);
// Verify existing parameters are preserved and spanId is added
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
// Verify navigation was called with all parameters
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
),
});
});
});

View File

@@ -0,0 +1,7 @@
export enum TraceWaterfallStates {
LOADING = 'LOADING',
SUCCESS = 'SUCCESS',
NO_DATA = 'NO_DATA',
ERROR = 'ERROR',
FETCHING_WITH_OLD_DATA_PRESENT = 'FETCHING_WTIH_OLD_DATA_PRESENT',
}

View File

@@ -0,0 +1,42 @@
.resizable-box {
position: relative;
overflow: hidden;
&--disabled {
flex: 1;
min-height: 0;
}
&__content {
width: 100%;
height: 100%;
overflow: hidden;
}
&__handle {
position: absolute;
z-index: 10;
background: var(--l2-border);
&:hover,
&:active {
background: var(--primary);
}
&--vertical {
bottom: 0;
left: 0;
right: 0;
height: 4px;
cursor: row-resize;
}
&--horizontal {
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
}
}
}

View File

@@ -0,0 +1,88 @@
import { useCallback, useRef, useState } from 'react';
import './ResizableBox.styles.scss';
export interface ResizableBoxProps {
children: React.ReactNode;
direction?: 'vertical' | 'horizontal';
defaultHeight?: number;
minHeight?: number;
maxHeight?: number;
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
onResize?: (size: number) => void;
disabled?: boolean;
className?: string;
}
function ResizableBox({
children,
direction = 'vertical',
defaultHeight = 200,
minHeight = 50,
maxHeight = Infinity,
defaultWidth = 200,
minWidth = 50,
maxWidth = Infinity,
onResize,
disabled = false,
className,
}: ResizableBoxProps): JSX.Element {
const isHorizontal = direction === 'horizontal';
const [size, setSize] = useState(isHorizontal ? defaultWidth : defaultHeight);
const containerRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback(
(e: React.MouseEvent): void => {
e.preventDefault();
const startPos = isHorizontal ? e.clientX : e.clientY;
const startSize = size;
const min = isHorizontal ? minWidth : minHeight;
const max = isHorizontal ? maxWidth : maxHeight;
const onMouseMove = (moveEvent: MouseEvent): void => {
const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = currentPos - startPos;
const newSize = Math.min(max, Math.max(min, startSize + delta));
setSize(newSize);
onResize?.(newSize);
};
const onMouseUp = (): void => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[size, isHorizontal, minWidth, maxWidth, minHeight, maxHeight, onResize],
);
const containerStyle = disabled
? undefined
: isHorizontal
? { width: size }
: { height: size };
const handleClass = `resizable-box__handle resizable-box__handle--${direction}`;
return (
<div
ref={containerRef}
className={`resizable-box ${disabled ? 'resizable-box--disabled' : ''} ${
className || ''
}`}
style={containerStyle}
>
<div className="resizable-box__content">{children}</div>
{!disabled && <div className={handleClass} onMouseDown={handleMouseDown} />}
</div>
);
}
export default ResizableBox;

View File

@@ -0,0 +1,2 @@
export type { ResizableBoxProps } from './ResizableBox';
export { default as ResizableBox } from './ResizableBox';

View File

@@ -1,115 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/aio11ypricingruletypes"
"github.com/gorilla/mux"
)
func (provider *provider) addAIO11yRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/ai-o11y/pricing/rules", handler.New(
provider.authZ.ViewAccess(provider.aiO11yPricingRuleHandler.List),
handler.OpenAPIDef{
ID: "ListPricingRules",
Tags: []string{"ai-o11y"},
Summary: "List pricing rules",
Description: "Returns all LLM pricing rules for the authenticated org, with pagination.",
Request: nil,
RequestContentType: "",
RequestQuery: new(aio11ypricingruletypes.ListPricingRulesQuery),
Response: new(aio11ypricingruletypes.ListPricingRulesResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/ai-o11y/pricing/rules", handler.New(
provider.authZ.AdminAccess(provider.aiO11yPricingRuleHandler.Create),
handler.OpenAPIDef{
ID: "CreatePricingRule",
Tags: []string{"ai-o11y"},
Summary: "Create a pricing rule",
Description: "Creates a new LLM pricing rule for the org. Always sets is_override = true.",
Request: new(aio11ypricingruletypes.PostablePricingRule),
RequestContentType: "application/json",
Response: new(aio11ypricingruletypes.GettablePricingRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/ai-o11y/pricing/rules/{id}", handler.New(
provider.authZ.ViewAccess(provider.aiO11yPricingRuleHandler.Get),
handler.OpenAPIDef{
ID: "GetPricingRule",
Tags: []string{"ai-o11y"},
Summary: "Get a pricing rule",
Description: "Returns a single LLM pricing rule by ID.",
Request: nil,
RequestContentType: "",
Response: new(aio11ypricingruletypes.GettablePricingRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/ai-o11y/pricing/rules/{id}", handler.New(
provider.authZ.AdminAccess(provider.aiO11yPricingRuleHandler.Update),
handler.OpenAPIDef{
ID: "UpdatePricingRule",
Tags: []string{"ai-o11y"},
Summary: "Update a pricing rule",
Description: "Partially updates an existing pricing rule. Changing any cost field sets is_override = true.",
Request: new(aio11ypricingruletypes.UpdatablePricingRule),
RequestContentType: "application/json",
Response: new(aio11ypricingruletypes.GettablePricingRule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/ai-o11y/pricing/rules/{id}", handler.New(
provider.authZ.AdminAccess(provider.aiO11yPricingRuleHandler.Delete),
handler.OpenAPIDef{
ID: "DeletePricingRule",
Tags: []string{"ai-o11y"},
Summary: "Delete a pricing rule",
Description: "Hard-deletes a pricing rule. If auto-synced, it will be recreated on the next sync cycle.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -41,7 +41,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
RequestContentType: "application/json",
Response: new(citypes.GettableAccountWithConnectionArtifact),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
@@ -138,7 +138,6 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Summary: "List services metadata",
Description: "This endpoint lists the services metadata for the specified cloud provider",
Request: nil,
RequestQuery: new(citypes.ListServicesMetadataParams),
RequestContentType: "",
Response: new(citypes.GettableServicesMetadata),
ResponseContentType: "application/json",
@@ -159,7 +158,6 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
Summary: "Get service",
Description: "This endpoint gets a service for the specified cloud provider",
Request: nil,
RequestQuery: new(citypes.GetServiceParams),
RequestContentType: "",
Response: new(citypes.Service),
ResponseContentType: "application/json",

View File

@@ -11,7 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/aio11ypricingrule"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -33,32 +32,31 @@ import (
)
type provider struct {
config apiserver.Config
settings factory.ScopedProviderSettings
router *mux.Router
authZ *middleware.AuthZ
orgHandler organization.Handler
userHandler user.Handler
sessionHandler session.Handler
authDomainHandler authdomain.Handler
preferenceHandler preference.Handler
globalHandler global.Handler
promoteHandler promote.Handler
flaggerHandler flagger.Handler
dashboardModule dashboard.Module
dashboardHandler dashboard.Handler
metricsExplorerHandler metricsexplorer.Handler
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
aiO11yPricingRuleHandler aio11ypricingrule.Handler
config apiserver.Config
settings factory.ScopedProviderSettings
router *mux.Router
authZ *middleware.AuthZ
orgHandler organization.Handler
userHandler user.Handler
sessionHandler session.Handler
authDomainHandler authdomain.Handler
preferenceHandler preference.Handler
globalHandler global.Handler
promoteHandler promote.Handler
flaggerHandler flagger.Handler
dashboardModule dashboard.Module
dashboardHandler dashboard.Handler
metricsExplorerHandler metricsexplorer.Handler
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
}
func NewFactory(
@@ -85,7 +83,6 @@ func NewFactory(
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
aiO11yPricingRuleHandler aio11ypricingrule.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -115,7 +112,6 @@ func NewFactory(
factoryHandler,
cloudIntegrationHandler,
ruleStateHistoryHandler,
aiO11yPricingRuleHandler,
)
})
}
@@ -147,37 +143,35 @@ func newProvider(
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
aiO11yPricingRuleHandler aio11ypricingrule.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
provider := &provider{
config: config,
settings: settings,
router: router,
orgHandler: orgHandler,
userHandler: userHandler,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,
preferenceHandler: preferenceHandler,
globalHandler: globalHandler,
promoteHandler: promoteHandler,
flaggerHandler: flaggerHandler,
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,
aiO11yPricingRuleHandler: aiO11yPricingRuleHandler,
config: config,
settings: settings,
router: router,
orgHandler: orgHandler,
userHandler: userHandler,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,
preferenceHandler: preferenceHandler,
globalHandler: globalHandler,
promoteHandler: promoteHandler,
flaggerHandler: flaggerHandler,
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -278,10 +272,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addAIO11yRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -1,26 +0,0 @@
package aio11ypricingrule
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/aio11ypricingruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*aio11ypricingruletypes.PricingRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*aio11ypricingruletypes.PricingRule, error)
Create(ctx context.Context, orgID valuer.UUID, createdBy string, req *aio11ypricingruletypes.PostablePricingRule) (*aio11ypricingruletypes.PricingRule, error)
Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, req *aio11ypricingruletypes.UpdatablePricingRule) (*aio11ypricingruletypes.PricingRule, error)
Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
}
// Handler defines the HTTP handler interface for pricing rule endpoints.
type Handler interface {
List(rw http.ResponseWriter, r *http.Request)
Get(rw http.ResponseWriter, r *http.Request)
Create(rw http.ResponseWriter, r *http.Request)
Update(rw http.ResponseWriter, r *http.Request)
Delete(rw http.ResponseWriter, r *http.Request)
}

View File

@@ -1,226 +0,0 @@
package implaio11ypricingrule
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/aio11ypricingrule"
"github.com/SigNoz/signoz/pkg/types/aio11ypricingruletypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
const maxLimit = 100
type handler struct {
module aio11ypricingrule.Module
providerSettings factory.ProviderSettings
}
func NewHandler(module aio11ypricingrule.Module, providerSettings factory.ProviderSettings) aio11ypricingrule.Handler {
return &handler{module: module, providerSettings: providerSettings}
}
// List handles GET /api/v1/ai-o11y/pricing/rules.
func (h *handler) List(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
var q aio11ypricingruletypes.ListPricingRulesQuery
if err := binding.Query.BindQuery(r.URL.Query(), &q); err != nil {
render.Error(rw, err)
return
}
if q.Limit <= 0 {
q.Limit = 20
} else if q.Limit > maxLimit {
q.Limit = maxLimit
}
if q.Offset < 0 {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, aio11ypricingruletypes.ErrCodePricingRuleInvalidInput, "offset must be a non-negative integer"))
return
}
rules, total, err := h.module.List(ctx, orgID, q.Offset, q.Limit)
if err != nil {
render.Error(rw, err)
return
}
items := make([]*aio11ypricingruletypes.GettablePricingRule, len(rules))
for i, r := range rules {
items[i] = aio11ypricingruletypes.NewGettablePricingRule(r)
}
render.Success(rw, http.StatusOK, &aio11ypricingruletypes.ListPricingRulesResponse{
Items: items,
Total: total,
Offset: q.Offset,
Limit: q.Limit,
})
}
// Get handles GET /api/v1/ai-o11y/pricing/rules/{id}.
func (h *handler) Get(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, err := ruleIDFromPath(r)
if err != nil {
render.Error(rw, err)
return
}
rule, err := h.module.Get(ctx, orgID, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, aio11ypricingruletypes.NewGettablePricingRule(rule))
}
// Create handles POST /api/v1/ai-o11y/pricing/rules.
func (h *handler) Create(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
req := new(aio11ypricingruletypes.PostablePricingRule)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
rule, err := h.module.Create(ctx, orgID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, aio11ypricingruletypes.NewGettablePricingRule(rule))
}
// Update handles PUT /api/v1/ai-o11y/pricing/rules/{id}
func (h *handler) Update(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, err := ruleIDFromPath(r)
if err != nil {
render.Error(rw, err)
return
}
req := new(aio11ypricingruletypes.UpdatablePricingRule)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
rule, err := h.module.Update(ctx, orgID, id, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, aio11ypricingruletypes.NewGettablePricingRule(rule))
}
// Delete handles DELETE /api/v1/ai-o11y/pricing/rules/{id}
func (h *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id, err := ruleIDFromPath(r)
if err != nil {
render.Error(rw, err)
return
}
if err := h.module.Delete(ctx, orgID, id); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
// ruleIDFromPath extracts and validates the {id} path variable.
func ruleIDFromPath(r *http.Request) (valuer.UUID, error) {
raw := mux.Vars(r)["id"]
if raw == "" {
return valuer.UUID{}, errors.Newf(errors.TypeInvalidInput, aio11ypricingruletypes.ErrCodePricingRuleInvalidInput, "id is missing from the path")
}
id, err := valuer.NewUUID(raw)
if err != nil {
return valuer.UUID{}, errors.Wrapf(err, errors.TypeInvalidInput, aio11ypricingruletypes.ErrCodePricingRuleInvalidInput, "id is not a valid uuid")
}
return id, nil
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/global/signozglobal"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/aio11ypricingrule"
"github.com/SigNoz/signoz/pkg/modules/apdex"
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -43,27 +42,26 @@ import (
)
type Handlers struct {
SavedView savedview.Handler
Apdex apdex.Handler
Dashboard dashboard.Handler
QuickFilter quickfilter.Handler
TraceFunnel tracefunnel.Handler
RawDataExport rawdataexport.Handler
SpanPercentile spanpercentile.Handler
Services services.Handler
MetricsExplorer metricsexplorer.Handler
Global global.Handler
FlaggerHandler flagger.Handler
GatewayHandler gateway.Handler
Fields fields.Handler
AuthzHandler authz.Handler
ZeusHandler zeus.Handler
QuerierHandler querier.Handler
ServiceAccountHandler serviceaccount.Handler
RegistryHandler factory.Handler
CloudIntegrationHandler cloudintegration.Handler
RuleStateHistory rulestatehistory.Handler
AIO11yPricingRuleHandler aio11ypricingrule.Handler
SavedView savedview.Handler
Apdex apdex.Handler
Dashboard dashboard.Handler
QuickFilter quickfilter.Handler
TraceFunnel tracefunnel.Handler
RawDataExport rawdataexport.Handler
SpanPercentile spanpercentile.Handler
Services services.Handler
MetricsExplorer metricsexplorer.Handler
Global global.Handler
FlaggerHandler flagger.Handler
GatewayHandler gateway.Handler
Fields fields.Handler
AuthzHandler authz.Handler
ZeusHandler zeus.Handler
QuerierHandler querier.Handler
ServiceAccountHandler serviceaccount.Handler
RegistryHandler factory.Handler
CloudIntegrationHandler cloudintegration.Handler
RuleStateHistory rulestatehistory.Handler
}
func NewHandlers(

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/aio11ypricingrule"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -70,7 +69,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ factory.Handler }{},
struct{ cloudintegration.Handler }{},
struct{ rulestatehistory.Handler }{},
struct{ aio11ypricingrule.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -3,6 +3,8 @@ package signoz
import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/noopauditor"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/analytics"
@@ -10,8 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/analytics/segmentanalytics"
"github.com/SigNoz/signoz/pkg/apiserver"
"github.com/SigNoz/signoz/pkg/apiserver/signozapiserver"
"github.com/SigNoz/signoz/pkg/auditor"
"github.com/SigNoz/signoz/pkg/auditor/noopauditor"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
@@ -288,7 +288,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RegistryHandler,
handlers.CloudIntegrationHandler,
handlers.RuleStateHistory,
handlers.AIO11yPricingRuleHandler,
),
)
}

View File

@@ -1,9 +0,0 @@
package aio11ypricingruletypes
import "github.com/SigNoz/signoz/pkg/errors"
var (
ErrCodePricingRuleNotFound = errors.MustNewCode("pricing_rule_not_found")
ErrCodePricingRuleAlreadyExists = errors.MustNewCode("pricing_rule_already_exists")
ErrCodePricingRuleInvalidInput = errors.MustNewCode("pricing_rule_invalid_input")
)

View File

@@ -1,91 +0,0 @@
package aio11ypricingruletypes
import "time"
// GettablePricingRule is the HTTP response representation of a pricing rule.
type GettablePricingRule struct {
ID string `json:"id" required:"true"`
Model string `json:"model_name" required:"true"`
ModelPattern []string `json:"model_pattern" required:"true"`
Unit Unit `json:"unit" required:"true"`
CacheMode CacheMode `json:"cache_mode" required:"true"`
CostInput float64 `json:"cost_input" required:"true"`
CostOutput float64 `json:"cost_output" required:"true"`
CostCacheRead float64 `json:"cost_cache_read" required:"true"`
CostCacheWrite float64 `json:"cost_cache_write" required:"true"`
IsOverride bool `json:"is_override" required:"true"`
SyncedAt *time.Time `json:"synced_at,omitempty"`
Enabled bool `json:"enabled" required:"true"`
CreatedAt time.Time `json:"created_at" required:"true"`
UpdatedAt time.Time `json:"updated_at" required:"true"`
CreatedBy string `json:"created_by" required:"true"`
UpdatedBy string `json:"updated_by" required:"true"`
}
// NewGettablePricingRule converts a domain PricingRule to a GettablePricingRule.
func NewGettablePricingRule(r *PricingRule) *GettablePricingRule {
pattern := make([]string, len(r.ModelPattern))
copy(pattern, r.ModelPattern)
return &GettablePricingRule{
ID: r.ID,
Model: r.Model,
ModelPattern: pattern,
Unit: r.Unit,
CacheMode: r.CacheMode,
CostInput: r.CostInput,
CostOutput: r.CostOutput,
CostCacheRead: r.CostCacheRead,
CostCacheWrite: r.CostCacheWrite,
IsOverride: r.IsOverride,
SyncedAt: r.SyncedAt,
Enabled: r.Enabled,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
CreatedBy: r.CreatedBy,
UpdatedBy: r.UpdatedBy,
}
}
// PostablePricingRule is the HTTP request body for creating a pricing rule.
// All fields are required.
type PostablePricingRule struct {
Model string `json:"model_name" required:"true"`
ModelPattern []string `json:"model_pattern" required:"true"`
Unit Unit `json:"unit" required:"true"`
CacheMode CacheMode `json:"cache_mode" required:"true"`
CostInput float64 `json:"cost_input" required:"true"`
CostOutput float64 `json:"cost_output" required:"true"`
CostCacheRead float64 `json:"cost_cache_read" required:"true"`
CostCacheWrite float64 `json:"cost_cache_write" required:"true"`
Enabled bool `json:"enabled" required:"true"`
}
// UpdatablePricingRule is the HTTP request body for updating a pricing rule.
// All fields are optional; only non-nil fields are applied.
type UpdatablePricingRule struct {
Model *string `json:"model_name,omitempty"`
ModelPattern []string `json:"model_pattern,omitempty"`
Unit *Unit `json:"unit,omitempty"`
CacheMode *CacheMode `json:"cache_mode,omitempty"`
CostInput *float64 `json:"cost_input,omitempty"`
CostOutput *float64 `json:"cost_output,omitempty"`
CostCacheRead *float64 `json:"cost_cache_read,omitempty"`
CostCacheWrite *float64 `json:"cost_cache_write,omitempty"`
IsOverride *bool `json:"is_override,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// ListPricingRulesQuery holds the pagination parameters for listing pricing rules.
type ListPricingRulesQuery struct {
Offset int `query:"offset" json:"offset"`
Limit int `query:"limit" json:"limit"`
}
// ListPricingRulesResponse is the paginated response for listing pricing rules.
type ListPricingRulesResponse struct {
Items []*GettablePricingRule `json:"items" required:"true" nullable:"true"`
Total int `json:"total" required:"true"`
Offset int `json:"offset" required:"true"`
Limit int `json:"limit" required:"true"`
}

View File

@@ -1,80 +0,0 @@
package aio11ypricingruletypes
import (
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Unit string
const (
UnitPerMillionTokens Unit = "per_million_tokens"
)
func (Unit) Enum() []any {
return []any{UnitPerMillionTokens}
}
type CacheMode string
const (
// CacheModeSubtract: cached tokens are inside input_tokens (OpenAI-style).
CacheModeSubtract CacheMode = "subtract"
// CacheModeAdditive: cached tokens are reported separately (Anthropic-style).
CacheModeAdditive CacheMode = "additive"
// CacheModeUnknown: provider behaviour is unknown; falls back to subtract.
CacheModeUnknown CacheMode = "unknown"
)
func (CacheMode) Enum() []any {
return []any{CacheModeSubtract, CacheModeAdditive, CacheModeUnknown}
}
// PricingRule is the domain model for an LLM pricing rule.
// It has no serialisation concerns — use GettablePricingRule for HTTP responses.
type PricingRule struct {
types.TimeAuditable
types.UserAuditable
ID string
OrgID valuer.UUID
Model string
ModelPattern []string
Unit Unit
CacheMode CacheMode
CostInput float64
CostOutput float64
CostCacheRead float64
CostCacheWrite float64
// IsOverride marks that cost fields were manually edited.
// When true the sync job skips this rule.
IsOverride bool
SyncedAt *time.Time
Enabled bool
}
// NewPricingRuleFromStorable converts a StorablePricingRule to a PricingRule.
func NewPricingRuleFromStorable(s *StorablePricingRule) *PricingRule {
pattern := make([]string, len(s.ModelPattern))
copy(pattern, s.ModelPattern)
return &PricingRule{
TimeAuditable: s.TimeAuditable,
UserAuditable: s.UserAuditable,
ID: s.ID.StringValue(),
OrgID: s.OrgID,
Model: s.Model,
ModelPattern: pattern,
Unit: s.Unit,
CacheMode: s.CacheMode,
CostInput: s.CostInput,
CostOutput: s.CostOutput,
CostCacheRead: s.CostCacheRead,
CostCacheWrite: s.CostCacheWrite,
IsOverride: s.IsOverride,
SyncedAt: s.SyncedAt,
Enabled: s.Enabled,
}
}

View File

@@ -1,70 +0,0 @@
package aio11ypricingruletypes
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
// StringSlice is a []string that is stored as a JSON text column.
// It is compatible with both SQLite and PostgreSQL.
type StringSlice []string
func (s StringSlice) Value() (driver.Value, error) {
if s == nil {
return "[]", nil
}
b, err := json.Marshal(s)
if err != nil {
return nil, err
}
return string(b), nil
}
func (s *StringSlice) Scan(src any) error {
var raw []byte
switch v := src.(type) {
case string:
raw = []byte(v)
case []byte:
raw = v
case nil:
*s = nil
return nil
default:
return fmt.Errorf("aio11ypricingruletypes: cannot scan %T into StringSlice", src)
}
return json.Unmarshal(raw, s)
}
// StorablePricingRule is the bun/DB representation of an LLM pricing rule.
type StorablePricingRule struct {
bun.BaseModel `bun:"table:llm_pricing_rules,alias:llm_pricing_rules"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
Model string `bun:"model,type:text,notnull"`
ModelPattern StringSlice `bun:"model_pattern,type:text,notnull"`
Unit Unit `bun:"unit,type:text,notnull"`
CacheMode CacheMode `bun:"cache_mode,type:text,notnull"`
CostInput float64 `bun:"cost_input,notnull"`
CostOutput float64 `bun:"cost_output,notnull"`
CostCacheRead float64 `bun:"cost_cache_read,notnull"`
CostCacheWrite float64 `bun:"cost_cache_write,notnull"`
// IsOverride marks that cost fields were manually edited.
// When true the sync job skips this rule.
IsOverride bool `bun:"is_override,notnull,default:false"`
// SourceConfig holds the last upstream pricing snapshot; used to revert
// when IsOverride is cleared.
SourceConfig string `bun:"source_config,type:text,notnull,default:'{}'"`
SyncedAt *time.Time `bun:"synced_at"`
Enabled bool `bun:"enabled,notnull,default:true"`
}

View File

@@ -1,19 +0,0 @@
package aio11ypricingruletypes
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Store interface {
List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*StorablePricingRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*StorablePricingRule, error)
Create(ctx context.Context, rule *StorablePricingRule) error
Update(ctx context.Context, rule *StorablePricingRule) error
Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
}

View File

@@ -62,10 +62,6 @@ type GettableServicesMetadata struct {
Services []*ServiceMetadata `json:"services" required:"true" nullable:"false"`
}
type ListServicesMetadataParams struct {
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
}
// Service represents a cloud integration service with its definition,
// cloud integration service is non nil only when the service entry exists in DB with ANY config (enabled or disabled).
type Service struct {
@@ -73,10 +69,6 @@ type Service struct {
CloudIntegrationService *CloudIntegrationService `json:"cloudIntegrationService" required:"true" nullable:"true"`
}
type GetServiceParams struct {
CloudIntegrationID valuer.UUID `query:"cloud_integration_id" required:"false"`
}
type UpdatableService struct {
Config *ServiceConfig `json:"config" required:"true" nullable:"false"`
}