mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-30 20:00:44 +01:00
Compare commits
4 Commits
issue_5325
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3385776cce | ||
|
|
f5286d69f6 | ||
|
|
bcbac9a15c | ||
|
|
43038c59b5 |
@@ -5160,16 +5160,6 @@ components:
|
||||
- offset
|
||||
- limit
|
||||
type: object
|
||||
LlmpricingruletypesGettableUnmappedModels:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesUnmappedModel'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
LlmpricingruletypesLLMPricingCacheCosts:
|
||||
properties:
|
||||
mode:
|
||||
@@ -5259,19 +5249,6 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
LlmpricingruletypesUnmappedModel:
|
||||
properties:
|
||||
modelName:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
spanCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- modelName
|
||||
- spanCount
|
||||
type: object
|
||||
LlmpricingruletypesUpdatableLLMPricingRule:
|
||||
properties:
|
||||
enabled:
|
||||
@@ -10995,60 +10972,6 @@ paths:
|
||||
summary: Get a pricing rule
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/llm_pricing_rules/unmapped_models:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns models seen in the last hour of trace data (gen_ai.request.model)
|
||||
that no pricing rule pattern matches, so the user can add them to an existing
|
||||
rule or create a new one.
|
||||
operationId: ListUnmappedLLMModels
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesGettableUnmappedModels'
|
||||
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 unmapped models
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/logs/promote_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
GetLLMPricingRulePathParameters,
|
||||
ListLLMPricingRules200,
|
||||
ListLLMPricingRulesParams,
|
||||
ListUnmappedLLMModels200,
|
||||
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -394,87 +393,3 @@ export const invalidateGetLLMPricingRule = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
export const listUnmappedLLMModels = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListUnmappedLLMModels200>({
|
||||
url: `/api/v1/llm_pricing_rules/unmapped_models`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListUnmappedLLMModelsQueryKey = () => {
|
||||
return [`/api/v1/llm_pricing_rules/unmapped_models`] as const;
|
||||
};
|
||||
|
||||
export const getListUnmappedLLMModelsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListUnmappedLLMModelsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>
|
||||
> = ({ signal }) => listUnmappedLLMModels(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListUnmappedLLMModelsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>
|
||||
>;
|
||||
export type ListUnmappedLLMModelsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
|
||||
export function useListUnmappedLLMModels<
|
||||
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListUnmappedLLMModelsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
export const invalidateListUnmappedLLMModels = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListUnmappedLLMModelsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -6756,29 +6756,6 @@ export interface LlmpricingruletypesGettablePricingRulesDTO {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesUnmappedModelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modelName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
provider?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
spanCount: number;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesGettableUnmappedModelsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
items: LlmpricingruletypesUnmappedModelDTO[] | null;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -9960,14 +9937,6 @@ export type GetLLMPricingRule200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUnmappedLLMModels200 = {
|
||||
data: LlmpricingruletypesGettableUnmappedModelsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPaths200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
|
||||
@@ -148,7 +148,7 @@ function AnalyticsPanel({
|
||||
className="floating-panel__drag-handle"
|
||||
/>
|
||||
|
||||
<div className={styles.body} data-testid="trace-analytics-panel">
|
||||
<div className={styles.body}>
|
||||
<TabsRoot defaultValue="exec-time" onValueChange={onTabChange}>
|
||||
<TabsList variant="secondary">
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
|
||||
@@ -60,7 +60,7 @@ function DockModeSwitcher({
|
||||
{DOCK_OPTIONS.map((option) => (
|
||||
<TooltipRoot key={option.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<span data-testid={`dock-mode-${option.value}`}>
|
||||
<span>
|
||||
<ToggleGroupItem value={option.value}>{option.icon}</ToggleGroupItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.root {
|
||||
.panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -9,13 +9,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
.panelBody {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
background: var(--l1-background);
|
||||
font-size: 14px;
|
||||
@@ -33,14 +34,17 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Single scroll: when the summary sits above (non-docked modes) it scrolls away
|
||||
// and the tab section pins to the top; tab content scrolls inside `.tabsScroll`.
|
||||
.tabsSection {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
|
||||
// TabsRoot — direct child of tabs-section
|
||||
> div {
|
||||
@@ -75,79 +79,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.spanRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.spanInfo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.spanInfoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.highlightedOptions {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
|
||||
// KeyValueLabel uses a global `.key-value-label` root; constrain it
|
||||
// inside the two-column grid so values can ellipsize cleanly.
|
||||
:global(.key-value-label) {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.serviceDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusMessageBadge {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.traceId {
|
||||
color: var(--accent-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.traceIdCopy {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// Tooltip is rendered in a portal but the SpanDetailsPanel can be docked as a
|
||||
// FloatingPanel (z-index 999), which would otherwise sit on top of the default
|
||||
// tooltip (z-index 50). Bump the tooltip above the panel.
|
||||
|
||||
@@ -5,20 +5,11 @@ import {
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import {
|
||||
Bookmark,
|
||||
CalendarClock,
|
||||
ChartColumnBig,
|
||||
Link2,
|
||||
List,
|
||||
ScrollText,
|
||||
Timer,
|
||||
} from '@signozhq/icons';
|
||||
import { Bookmark, ChartColumnBig, List, ScrollText } from '@signozhq/icons';
|
||||
import { Skeleton } from 'antd';
|
||||
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
|
||||
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
@@ -41,14 +32,13 @@ import {
|
||||
} from 'pages/TraceDetailsV3/utils';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
// KEY_ATTRIBUTE_KEYS, // uncomment when key attributes section is re-enabled
|
||||
SpanDetailVariant,
|
||||
@@ -57,17 +47,10 @@ import {
|
||||
import DockModeSwitcher from './DockModeSwitcher';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
useLinkedSpans,
|
||||
} from './LinkedSpans/LinkedSpans';
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
import Events from './Events/Events';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
|
||||
@@ -80,6 +63,10 @@ interface SpanDetailsPanelProps {
|
||||
traceEndTime?: number;
|
||||
}
|
||||
|
||||
// At/above this panel width the summary moves inside the Overview tab (bottom
|
||||
// dock, or a floating/right panel widened to match). ~right-dock max width.
|
||||
const WIDE_PANEL_BREAKPOINT = 720;
|
||||
|
||||
function SpanDetailsContent({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
@@ -90,6 +77,7 @@ function SpanDetailsContent({
|
||||
traceEndTime?: number;
|
||||
}): JSX.Element {
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
const [bodyRef, { width: bodyWidth }] = useMeasure<HTMLDivElement>();
|
||||
const spanAttributeActions = useSpanAttributeActions();
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', selectedSpan.trace_id);
|
||||
const handleTabChange = useCallback(
|
||||
@@ -101,8 +89,6 @@ function SpanDetailsContent({
|
||||
},
|
||||
[logTraceEvent, selectedSpan.span_id],
|
||||
);
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
// One-time conversion of any V2-format value still living in the
|
||||
// `span_details_pinned_attributes` user pref into V3 nested-path format.
|
||||
@@ -281,113 +267,20 @@ function SpanDetailsContent({
|
||||
// .map((key) => ({ key, value: allAttrs[key] }));
|
||||
// }, [selectedSpan]);
|
||||
|
||||
// Width-driven: when the panel is wide, the summary moves inside the Overview
|
||||
// tab; when narrow it stays above the tabs.
|
||||
const isWide = bodyWidth >= WIDE_PANEL_BREAKPOINT;
|
||||
const summary = (
|
||||
<SpanSummary
|
||||
selectedSpan={selectedSpan}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.detailsSection}>
|
||||
<div className={styles.spanRow}>
|
||||
<KeyValueLabel
|
||||
badgeKey="Span name"
|
||||
badgeValue={selectedSpan.name}
|
||||
maxCharacters={50}
|
||||
/>
|
||||
<SpanPercentileBadge
|
||||
loading={percentile.loading}
|
||||
percentileValue={percentile.percentileValue}
|
||||
duration={percentile.duration}
|
||||
spanPercentileData={percentile.spanPercentileData}
|
||||
isOpen={percentile.isOpen}
|
||||
toggleOpen={percentile.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
|
||||
|
||||
{/* Span info: exec time + start time */}
|
||||
<div className={styles.spanInfo}>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<Timer size={14} />
|
||||
<span>
|
||||
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
|
||||
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
|
||||
<>
|
||||
{' — '}
|
||||
<strong>
|
||||
{(
|
||||
(selectedSpan.duration_nano * 100) /
|
||||
((traceEndTime - traceStartTime) * 1e6)
|
||||
).toFixed(2)}
|
||||
%
|
||||
</strong>
|
||||
{' of total exec time'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<CalendarClock size={14} />
|
||||
<span>
|
||||
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<Link2 size={14} />
|
||||
<LinkedSpansToggle
|
||||
count={linkedSpans.count}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
toggleOpen={linkedSpans.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkedSpansPanel
|
||||
linkedSpans={linkedSpans.linkedSpans}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
/>
|
||||
|
||||
{/* Step 6: HighlightedOptions */}
|
||||
<div className={styles.highlightedOptions}>
|
||||
{HIGHLIGHTED_OPTIONS.map((option) => {
|
||||
const rendered = option.render(selectedSpan);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<KeyValueLabel
|
||||
key={option.key}
|
||||
badgeKey={option.label}
|
||||
badgeValue={rendered}
|
||||
direction="column"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step 7: KeyAttributes — commented out, pinning in PrettyView covers this.
|
||||
{keyAttributes.length > 0 && (
|
||||
<div className="span-details-panel__key-attributes">
|
||||
<div className="span-details-panel__key-attributes-label">
|
||||
KEY ATTRIBUTES
|
||||
</div>
|
||||
<div className="span-details-panel__key-attributes-chips">
|
||||
{keyAttributes.map(({ key, value }) => (
|
||||
<ActionMenu
|
||||
key={key}
|
||||
items={buildKeyAttrMenu(key, value)}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<div>
|
||||
<KeyValueLabel badgeKey={key} badgeValue={value} />
|
||||
</div>
|
||||
</ActionMenu>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Step 8: MiniTraceContext */}
|
||||
</div>
|
||||
<div className={styles.panelBody} ref={bodyRef}>
|
||||
{!isWide && <div className={styles.detailsSection}>{summary}</div>}
|
||||
|
||||
<div className={styles.tabsSection}>
|
||||
{/* Step 9: ContentTabs */}
|
||||
@@ -411,6 +304,7 @@ function SpanDetailsContent({
|
||||
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
@@ -535,7 +429,7 @@ function SpanDetailsPanel({
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.body}>
|
||||
<div className={styles.panelBody}>
|
||||
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
|
||||
</div>
|
||||
)}
|
||||
@@ -546,7 +440,7 @@ function SpanDetailsPanel({
|
||||
variant === SpanDetailVariant.DOCKED ||
|
||||
variant === SpanDetailVariant.DOCKED_RIGHT
|
||||
) {
|
||||
return <div className={styles.root}>{content}</div>;
|
||||
return <div className={styles.panel}>{content}</div>;
|
||||
}
|
||||
|
||||
if (variant === SpanDetailVariant.DRAWER) {
|
||||
@@ -554,7 +448,7 @@ function SpanDetailsPanel({
|
||||
<DetailsPanelDrawer
|
||||
isOpen={panelState.isOpen}
|
||||
onClose={panelState.close}
|
||||
className={styles.root}
|
||||
className={styles.panel}
|
||||
>
|
||||
{content}
|
||||
</DetailsPanelDrawer>
|
||||
@@ -564,7 +458,7 @@ function SpanDetailsPanel({
|
||||
return (
|
||||
<FloatingPanel
|
||||
isOpen={panelState.isOpen}
|
||||
className={styles.root}
|
||||
className={styles.panel}
|
||||
width={PANEL_WIDTH}
|
||||
minWidth={480}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
margin: 8px 16px;
|
||||
margin: 8px 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -16,7 +17,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -162,20 +163,20 @@
|
||||
|
||||
.resourceSelector {
|
||||
overflow: hidden;
|
||||
width: calc(100% + 16px);
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: -8px;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.resourceSelectorHeader {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.resourceSelectorInput {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
.spanRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.spanInfo {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.spanInfoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.highlightedOptions {
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
|
||||
// Constrain KeyValueLabel inside the grid so values can ellipsize cleanly.
|
||||
:global(.key-value-label) {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.serviceDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Badges stay fit-content so short values shrink and long ones truncate.
|
||||
.serviceBadge,
|
||||
.statusMessageBadge {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Truncating text inside a badge (service name, status message).
|
||||
.badgeEllipsisText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { CalendarClock, Link2, Timer } from '@signozhq/icons';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
useLinkedSpans,
|
||||
} from './LinkedSpans/LinkedSpans';
|
||||
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
|
||||
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
|
||||
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
|
||||
|
||||
import styles from './SpanSummary.module.scss';
|
||||
|
||||
interface SpanSummaryProps {
|
||||
selectedSpan: SpanV3;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
}
|
||||
|
||||
// Summary block shown above (narrow) / beside (wide) the tabs: span name +
|
||||
// percentile, exec time / timestamp / linked spans, and the highlighted options.
|
||||
function SpanSummary({
|
||||
selectedSpan,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.spanRow}>
|
||||
<KeyValueLabel
|
||||
badgeKey="Span name"
|
||||
badgeValue={selectedSpan.name}
|
||||
maxCharacters={50}
|
||||
/>
|
||||
<SpanPercentileBadge
|
||||
loading={percentile.loading}
|
||||
percentileValue={percentile.percentileValue}
|
||||
duration={percentile.duration}
|
||||
spanPercentileData={percentile.spanPercentileData}
|
||||
isOpen={percentile.isOpen}
|
||||
toggleOpen={percentile.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
|
||||
|
||||
<div className={styles.spanInfo}>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<Timer size={14} />
|
||||
<span>
|
||||
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
|
||||
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
|
||||
<>
|
||||
{' — '}
|
||||
<strong>
|
||||
{(
|
||||
(selectedSpan.duration_nano * 100) /
|
||||
((traceEndTime - traceStartTime) * 1e6)
|
||||
).toFixed(2)}
|
||||
%
|
||||
</strong>
|
||||
{' of total exec time'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<CalendarClock size={14} />
|
||||
<span>
|
||||
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.spanInfoItem}>
|
||||
<Link2 size={14} />
|
||||
<LinkedSpansToggle
|
||||
count={linkedSpans.count}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
toggleOpen={linkedSpans.toggleOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkedSpansPanel
|
||||
linkedSpans={linkedSpans.linkedSpans}
|
||||
isOpen={linkedSpans.isOpen}
|
||||
/>
|
||||
|
||||
<div className={styles.highlightedOptions}>
|
||||
{HIGHLIGHTED_OPTIONS.map((option) => {
|
||||
const rendered = option.render(selectedSpan);
|
||||
if (!rendered) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<KeyValueLabel
|
||||
key={option.key}
|
||||
badgeKey={option.label}
|
||||
badgeValue={rendered}
|
||||
direction="column"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanSummary;
|
||||
@@ -0,0 +1,18 @@
|
||||
.traceId {
|
||||
color: var(--accent-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.traceIdCopy {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { toast } from '@signozhq/ui/sonner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
import styles from './TraceIdField.module.scss';
|
||||
|
||||
interface TraceIdFieldProps {
|
||||
span: SpanV3;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Badge } from '@signozhq/ui/badge';
|
||||
import ExpandableValue from 'periscope/components/ExpandableValue';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
import styles from './SpanSummary.module.scss';
|
||||
import { TraceIdField } from './TraceIdField';
|
||||
|
||||
interface HighlightedOption {
|
||||
@@ -18,9 +18,11 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
label: 'SERVICE',
|
||||
render: (span): ReactNode | null =>
|
||||
span['service.name'] ? (
|
||||
<Badge color="vanilla">
|
||||
<Badge color="vanilla" className={styles.serviceBadge}>
|
||||
<span className={styles.serviceDot} />
|
||||
{span['service.name']}
|
||||
<span className={styles.badgeEllipsisText} title={span['service.name']}>
|
||||
{span['service.name']}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null,
|
||||
},
|
||||
@@ -50,12 +52,8 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
render: (span): ReactNode | null =>
|
||||
span.status_message ? (
|
||||
<ExpandableValue value={span.status_message} title="Status message">
|
||||
<Badge
|
||||
color="vanilla"
|
||||
textEllipsis="end"
|
||||
className={styles.statusMessageBadge}
|
||||
>
|
||||
{span.status_message}
|
||||
<Badge color="vanilla" className={styles.statusMessageBadge}>
|
||||
<span className={styles.badgeEllipsisText}>{span.status_message}</span>
|
||||
</Badge>
|
||||
</ExpandableValue>
|
||||
) : null,
|
||||
|
||||
@@ -64,11 +64,7 @@ export function SpanTooltipContent({
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className={styles.preview}>
|
||||
{previewRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<div key={row.key} className={styles.row}>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
.rightDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
min-width: 0;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Shared Ant Collapse chrome reset for both the flamegraph and waterfall
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
|
||||
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
|
||||
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
|
||||
import { useFlamegraphHover } from './hooks/useFlamegraphHover';
|
||||
import { useFlamegraphTestHook } from './hooks/useFlamegraphTestHook';
|
||||
import { useFlamegraphZoom } from './hooks/useFlamegraphZoom';
|
||||
import { useScrollToSpan } from './hooks/useScrollToSpan';
|
||||
import { EventRect, FlamegraphCanvasProps, SpanRect } from './types';
|
||||
@@ -160,14 +159,6 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
|
||||
useCanvasSetup(canvasRef, containerRef, drawFlamegraph, overlayCanvasRef);
|
||||
|
||||
// E2E-only: expose the live span→rect map so specs can target canvas bars.
|
||||
// No-op unless window.__SIGNOZ_E2E__ is set (Playwright addInitScript).
|
||||
useFlamegraphTestHook({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spanRectsRef,
|
||||
});
|
||||
|
||||
const {
|
||||
cursorXPercent,
|
||||
cursorX,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
|
||||
import { SpanRect } from '../types';
|
||||
|
||||
/**
|
||||
* E2E test hook for the canvas flamegraph. The flamegraph is `<canvas>`, so
|
||||
* individual bars have no DOM nodes to target — but `spanRectsRef` already
|
||||
* holds the live span→rectangle map (CSS pixels) used for hit-testing. This
|
||||
* exposes a thin, read-only view of it on `window.__sigTraceFlame__` so a
|
||||
* Playwright spec can resolve a span's on-screen point and drive real
|
||||
* hover/click events at it (see tests/e2e/helpers/trace-details.ts).
|
||||
*
|
||||
* Gated on `window.__SIGNOZ_E2E__` (set by Playwright via addInitScript), so
|
||||
* nothing is attached in normal runtime — the e2e build is a production build,
|
||||
* so this must be a RUNTIME flag, not a NODE_ENV/mode check.
|
||||
*/
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface FlamegraphTestApi {
|
||||
getSpanPoint: (spanId: string) => Point | null;
|
||||
isSpanInView: (spanId: string) => boolean;
|
||||
// Resting group color of a span's bar — changes when colour-by changes.
|
||||
getSpanColor: (spanId: string) => string | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SIGNOZ_E2E__?: boolean;
|
||||
__sigTraceFlame__?: FlamegraphTestApi;
|
||||
}
|
||||
}
|
||||
|
||||
// Inverse of `getCanvasPointer` in useFlamegraphHover: a CSS-space span rect
|
||||
// maps back to a viewport point at the bar's center.
|
||||
function rectToViewportCenter(canvas: HTMLCanvasElement, r: SpanRect): Point {
|
||||
const box = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssWidth = canvas.width / dpr;
|
||||
const cssHeight = canvas.height / dpr;
|
||||
const cssX = r.x + r.width / 2;
|
||||
const cssY = r.y + r.height / 2;
|
||||
return {
|
||||
x: box.left + cssX * (box.width / cssWidth),
|
||||
y: box.top + cssY * (box.height / cssHeight),
|
||||
};
|
||||
}
|
||||
|
||||
interface UseFlamegraphTestHookParams {
|
||||
canvasRef: MutableRefObject<HTMLCanvasElement | null>;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
spanRectsRef: MutableRefObject<SpanRect[]>;
|
||||
}
|
||||
|
||||
export function useFlamegraphTestHook({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
spanRectsRef,
|
||||
}: UseFlamegraphTestHookParams): void {
|
||||
useEffect(() => {
|
||||
if (!window.__SIGNOZ_E2E__) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Reads `.current` at call time, so it always reflects the latest draw.
|
||||
const findRect = (spanId: string): SpanRect | undefined =>
|
||||
spanRectsRef.current.find((r) => r.span.spanId === spanId);
|
||||
|
||||
window.__sigTraceFlame__ = {
|
||||
getSpanPoint: (spanId): Point | null => {
|
||||
const canvas = canvasRef.current;
|
||||
const rect = findRect(spanId);
|
||||
return canvas && rect ? rectToViewportCenter(canvas, rect) : null;
|
||||
},
|
||||
isSpanInView: (spanId): boolean => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
const rect = findRect(spanId);
|
||||
if (!canvas || !container || !rect) {
|
||||
return false;
|
||||
}
|
||||
const pt = rectToViewportCenter(canvas, rect);
|
||||
const box = container.getBoundingClientRect();
|
||||
return (
|
||||
pt.x >= box.left &&
|
||||
pt.x <= box.right &&
|
||||
pt.y >= box.top &&
|
||||
pt.y <= box.bottom
|
||||
);
|
||||
},
|
||||
getSpanColor: (spanId): string | null => findRect(spanId)?.color ?? null,
|
||||
};
|
||||
|
||||
return (): void => {
|
||||
delete window.__sigTraceFlame__;
|
||||
};
|
||||
}, [canvasRef, containerRef, spanRectsRef]);
|
||||
}
|
||||
@@ -28,9 +28,6 @@ export interface SpanRect {
|
||||
width: number;
|
||||
height: number;
|
||||
level: number;
|
||||
// Resting fill color for the current colour-by grouping. Optional: only the
|
||||
// draw path sets it; consumers (e.g. the e2e colour-by hook) read it.
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface EventRect {
|
||||
|
||||
@@ -279,9 +279,6 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
width,
|
||||
height: metrics.SPAN_BAR_HEIGHT,
|
||||
level: levelIndex,
|
||||
// Resting group color (selected/hovered bars override the fill, but this
|
||||
// still reflects the colour-by grouping — used by the e2e colour-by hook).
|
||||
color: isDarkMode ? color : colorDark,
|
||||
});
|
||||
|
||||
span.event?.forEach((event) => {
|
||||
|
||||
@@ -259,10 +259,7 @@ function Filters({
|
||||
);
|
||||
|
||||
const highlightErrorsToggle = (
|
||||
<div
|
||||
className={styles.highlightErrorsToggle}
|
||||
data-testid="highlight-errors-toggle"
|
||||
>
|
||||
<div className={styles.highlightErrorsToggle}>
|
||||
<Typography.Text>Highlight errors</Typography.Text>
|
||||
<Switch
|
||||
color="cherry"
|
||||
|
||||
@@ -246,19 +246,6 @@ const SpanOverview = memo(function SpanOverview({
|
||||
onAddSpanToFunnel(span);
|
||||
};
|
||||
|
||||
// e2e hook: expose the filter highlight/dim state as a stable attribute, since
|
||||
// the styles.* classes are hashed at build time and can't be asserted.
|
||||
let spanState = 'default';
|
||||
if (isHighlighted) {
|
||||
spanState = 'highlighted';
|
||||
} else if (isDimmed) {
|
||||
spanState = 'dimmed';
|
||||
} else if (isSelectedNonMatching) {
|
||||
spanState = 'selected-non-matching';
|
||||
} else if (isSelected) {
|
||||
spanState = 'selected';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.spanOverview, {
|
||||
@@ -267,7 +254,6 @@ const SpanOverview = memo(function SpanOverview({
|
||||
[styles.isSelectedNonMatching]: isSelectedNonMatching,
|
||||
[styles.isDimmed]: isDimmed,
|
||||
})}
|
||||
data-span-state={spanState}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
onMouseEnter={(): void => onHoverEnter(span.span_id)}
|
||||
onMouseLeave={(): void => onHoverLeave()}
|
||||
@@ -315,7 +301,6 @@ const SpanOverview = memo(function SpanOverview({
|
||||
{span.has_children && (
|
||||
<span
|
||||
className={styles.treeArrow}
|
||||
data-testid={`cell-collapse-${span.span_id}`}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
@@ -49,26 +49,6 @@ func (provider *provider) addLLMPricingRuleRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/llm_pricing_rules/unmapped_models", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.ListUnmappedModels),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListUnmappedLLMModels",
|
||||
Tags: []string{"llmpricingrules"},
|
||||
Summary: "List unmapped models",
|
||||
Description: "Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(llmpricingruletypes.GettableUnmappedModels),
|
||||
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/llm_pricing_rules/{id}", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.Get),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -92,7 +92,7 @@ func (h *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
@@ -118,28 +118,6 @@ func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// ListUnmappedModels handles GET /api/v1/llm_pricing_rules/unmapped_models.
|
||||
func (h *handler) ListUnmappedModels(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 := valuer.MustNewUUID(claims.OrgID)
|
||||
|
||||
models, err := h.module.ListUnmappedModels(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, llmpricingruletypes.NewGettableUnmappedModels(models))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/llm_pricing_rules/{id}.
|
||||
func (h *handler) Delete(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
|
||||
@@ -3,32 +3,24 @@ package impllmpricingrule
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// unmappedModelsLookback is the trace data window scanned to discover models in use.
|
||||
const unmappedModelsLookback = time.Hour
|
||||
|
||||
type module struct {
|
||||
store llmpricingruletypes.Store
|
||||
querier querier.Querier
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger, querier querier.Querier) llmpricingrule.Module {
|
||||
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger) llmpricingrule.Module {
|
||||
return &module{store: store, flagger: flagger}
|
||||
}
|
||||
|
||||
@@ -40,28 +32,6 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
|
||||
return module.store.Get(ctx, orgID, id)
|
||||
}
|
||||
|
||||
// ListUnmappedModels discovers the models present in the last hour of trace data
|
||||
// (gen_ai.request.model) and returns the ones that no pricing rule pattern matches.
|
||||
func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
|
||||
models, err := module.discoverModels(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules, err := module.listAllRules(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
|
||||
for _, m := range models {
|
||||
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
|
||||
unmapped = append(unmapped, m)
|
||||
}
|
||||
}
|
||||
return unmapped, nil
|
||||
}
|
||||
|
||||
// CreateOrUpdate applies a batch of pricing rule changes:
|
||||
// - ID set → match by id, overwrite fields.
|
||||
// - SourceID set → match by source_id; if found overwrite, else insert.
|
||||
@@ -151,7 +121,7 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
|
||||
}
|
||||
|
||||
func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
|
||||
rules, err := module.listAllRules(ctx, orgID)
|
||||
rules, _, err := module.List(ctx, orgID, 0, 10000, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -165,25 +135,6 @@ func (module *module) getEnabledRules(ctx context.Context, orgID valuer.UUID) ([
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// listAllRules pages through every pricing rule for the org, since rule matching
|
||||
// needs the full set and the count is otherwise unbounded.
|
||||
func (module *module) listAllRules(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.LLMPricingRule, error) {
|
||||
const pageSize = 1000
|
||||
|
||||
all := make([]*llmpricingruletypes.LLMPricingRule, 0)
|
||||
for offset := 0; ; offset += pageSize {
|
||||
page, total, err := module.store.List(ctx, orgID, offset, pageSize, "", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all = append(all, page...)
|
||||
if len(page) == 0 || len(all) >= total {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// findExisting returns the row matching the updatable's ID or SourceID.
|
||||
// Returns a TypeNotFound error when neither matches; the caller treats that
|
||||
// as "insert new".
|
||||
@@ -197,91 +148,3 @@ func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u *ll
|
||||
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
|
||||
}
|
||||
}
|
||||
|
||||
// discoverModels runs a QBv5 traces aggregation grouped by gen_ai.request.model
|
||||
// over the lookback window and returns each distinct model with its span count.
|
||||
func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
|
||||
now := time.Now()
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(now.Add(-unmappedModelsLookback).UnixMilli()),
|
||||
End: uint64(now.UnixMilli()),
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()", Alias: "spanCount"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIRequestModel,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: llmpricingruletypes.GenAIProviderName,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
},
|
||||
Limit: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := module.querier.QueryRange(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp == nil || len(resp.Data.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sd, ok := resp.Data.Results[0].(*qbtypes.ScalarData)
|
||||
if !ok || sd == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
modelIdx, providerIdx, countIdx := -1, -1, -1
|
||||
for i, c := range sd.Columns {
|
||||
switch c.Type {
|
||||
case qbtypes.ColumnTypeGroup:
|
||||
switch c.Name {
|
||||
case llmpricingruletypes.GenAIRequestModel:
|
||||
modelIdx = i
|
||||
case llmpricingruletypes.GenAIProviderName:
|
||||
providerIdx = i
|
||||
}
|
||||
case qbtypes.ColumnTypeAggregation:
|
||||
countIdx = i
|
||||
}
|
||||
}
|
||||
if modelIdx == -1 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
models := make([]*llmpricingruletypes.UnmappedModel, 0, len(sd.Data))
|
||||
for _, row := range sd.Data {
|
||||
name, _ := row[modelIdx].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
provider := ""
|
||||
if providerIdx != -1 {
|
||||
provider, _ = row[providerIdx].(string)
|
||||
}
|
||||
var spanCount uint64
|
||||
if countIdx >= 0 && countIdx < len(row) {
|
||||
spanCount, _ = row[countIdx].(uint64)
|
||||
}
|
||||
models = append(models, &llmpricingruletypes.UnmappedModel{ModelName: name, Provider: provider, SpanCount: spanCount})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ type Module interface {
|
||||
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
|
||||
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
|
||||
Delete(ctx context.Context, orgID, id valuer.UUID) error
|
||||
ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error)
|
||||
}
|
||||
|
||||
// Handler defines the HTTP handler interface for pricing rule endpoints.
|
||||
@@ -26,5 +25,4 @@ type Handler interface {
|
||||
Get(rw http.ResponseWriter, r *http.Request)
|
||||
CreateOrUpdate(rw http.ResponseWriter, r *http.Request)
|
||||
Delete(rw http.ResponseWriter, r *http.Request)
|
||||
ListUnmappedModels(rw http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func NewModules(
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl),
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package llmpricingruletypes
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -17,7 +16,6 @@ const (
|
||||
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
|
||||
|
||||
GenAIRequestModel = "gen_ai.request.model"
|
||||
GenAIProviderName = "gen_ai.provider.name"
|
||||
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
@@ -141,17 +139,6 @@ type GettablePricingRules struct {
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// Models deleted from spans which doesn't have a corresponding pricing entry.
|
||||
type UnmappedModel struct {
|
||||
ModelName string `json:"modelName" required:"true"`
|
||||
Provider string `json:"provider"`
|
||||
SpanCount uint64 `json:"spanCount" required:"true"`
|
||||
}
|
||||
|
||||
type GettableUnmappedModels struct {
|
||||
Items []*UnmappedModel `json:"items" required:"true"`
|
||||
}
|
||||
|
||||
func (LLMPricingRuleUnit) Enum() []any {
|
||||
return []any{UnitPerMillionTokens}
|
||||
}
|
||||
@@ -220,12 +207,6 @@ func NewGettableLLMPricingRulesFromLLMPricingRules(items []*LLMPricingRule, tota
|
||||
}
|
||||
}
|
||||
|
||||
func NewGettableUnmappedModels(items []*UnmappedModel) *GettableUnmappedModels {
|
||||
return &GettableUnmappedModels{
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func NewLLMPricingRuleFromUpdatable(u *UpdatableLLMPricingRule, orgID valuer.UUID, userEmail string, now time.Time) *LLMPricingRule {
|
||||
isOverride := true
|
||||
if u.IsOverride != nil {
|
||||
@@ -270,14 +251,3 @@ func (r *LLMPricingRule) Update(u *UpdatableLLMPricingRule, userEmail string, no
|
||||
r.UpdatedAt = now
|
||||
r.UpdatedBy = userEmail
|
||||
}
|
||||
|
||||
func ModelMatchesAnyRule(model string, rules []*LLMPricingRule) bool {
|
||||
for _, r := range rules {
|
||||
for _, pattern := range r.ModelPattern {
|
||||
if ok, err := path.Match(pattern, model); err == nil && ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Shared helpers used across feature-specific helper modules (dashboards,
|
||||
// trace-details, …). Keep this to genuinely cross-feature utilities.
|
||||
|
||||
// ─── Seeder ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Base URL of the HTTP seeder container the pytest harness brings up (exposes
|
||||
// POST/DELETE on /telemetry/{traces,logs,metrics}). Written to
|
||||
// `tests/e2e/.env.local` as `SIGNOZ_E2E_SEEDER_URL` and read here from the env.
|
||||
export function seederUrl(): string {
|
||||
const url = process.env.SIGNOZ_E2E_SEEDER_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'SIGNOZ_E2E_SEEDER_URL not set — pytest test_setup must be running.',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Read the app JWT from the context's stored auth state. No navigation needed:
|
||||
// the auth fixture loads the admin storageState (localStorage AUTH_TOKEN) into
|
||||
// the context at creation, so storageState() returns it regardless of the page's
|
||||
// current URL. Server-side APIs need this as a Bearer token (auth is
|
||||
// JWT-in-localStorage, not cookies, so request.* doesn't carry it automatically).
|
||||
export async function authToken(page: Page): Promise<string> {
|
||||
const state = await page.context().storageState();
|
||||
for (const origin of state.origins) {
|
||||
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
|
||||
if (entry) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
throw new Error('AUTH_TOKEN not found in storage state — is the page authed?');
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
|
||||
import largeTraceRecords from '../testdata/traces/large-trace.json';
|
||||
import { authToken, seederUrl } from './common';
|
||||
|
||||
// ── Seeder: insert traces via POST /telemetry/traces ─────────────────────────
|
||||
|
||||
// Shape accepted by the seeder's POST /telemetry/traces endpoint
|
||||
// (mirrors `Traces.from_dict` in tests/fixtures/traces.py). One object per span;
|
||||
// spans sharing a `trace_id` form one trace, linked into a tree via
|
||||
// `parent_span_id`. NOTE: the endpoint does NOT ingest span events/links.
|
||||
export interface SeederSpan {
|
||||
timestamp: string; // ISO-8601, e.g. new Date().toISOString()
|
||||
trace_id: string; // 32 hex chars
|
||||
span_id: string; // 16 hex chars
|
||||
parent_span_id?: string; // empty/omitted = root span
|
||||
name?: string;
|
||||
kind?: number; // 1=internal 2=server 3=client 4=producer 5=consumer
|
||||
status_code?: number; // 0=unset 1=ok 2=error
|
||||
status_message?: string;
|
||||
duration?: string; // ISO-8601 duration, e.g. "PT0.12S" (default PT1S)
|
||||
resources?: Record<string, string>; // include 'service.name'
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 16-byte trace id / 8-byte span id, matching tests/fixtures/traces.py.
|
||||
export const randomTraceId = (): string => randomBytes(16).toString('hex');
|
||||
export const randomSpanId = (): string => randomBytes(8).toString('hex');
|
||||
|
||||
// Insert spans into the backend via the seeder. No auth needed (direct seeder
|
||||
// call), so any APIRequestContext works — `page.request` or a standalone
|
||||
// `playwright.request.newContext()` (cheaper than a full browser page for a
|
||||
// pure API call).
|
||||
//
|
||||
// The seeder shares a single ClickHouse client, so concurrent POSTs from
|
||||
// parallel workers collide with a 500 "concurrent queries within the same
|
||||
// session". That's transient, so retry with backoff; any other error is real.
|
||||
export async function seedTracesViaSeeder(
|
||||
request: APIRequestContext,
|
||||
spans: SeederSpan[],
|
||||
): Promise<void> {
|
||||
const url = `${seederUrl()}/telemetry/traces`;
|
||||
const maxAttempts = 6;
|
||||
let lastStatus = 0;
|
||||
let lastText = '';
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await request.post(url, {
|
||||
data: spans,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.ok()) {
|
||||
return;
|
||||
}
|
||||
lastStatus = res.status();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
lastText = await res.text();
|
||||
if (!(lastStatus === 500 && lastText.includes('concurrent'))) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 150 * (attempt + 1) + Math.floor(Math.random() * 100));
|
||||
});
|
||||
}
|
||||
throw new Error(`seeder POST /telemetry/traces ${lastStatus}: ${lastText}`);
|
||||
}
|
||||
|
||||
// ── Navigation ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Pages that already had the e2e test-hook init script registered, so
|
||||
// gotoTraceUntilLoaded adds it at most once per Page (addInitScript re-runs on
|
||||
// every navigation, and the script would otherwise stack up across calls).
|
||||
const e2eHookRegistered = new WeakSet<Page>();
|
||||
|
||||
// Open a seeded trace and wait until the waterfall has rendered. The trace page
|
||||
// fetches once on load, so if the seed isn't query-able yet (ClickHouse lag, worse
|
||||
// under parallel load) it lands on the NoData state and never refetches — this
|
||||
// reloads until the given row testid appears. Makes seeded-trace specs
|
||||
// deterministic in the full parallel run, not just when run alone.
|
||||
export async function gotoTraceUntilLoaded(
|
||||
page: Page,
|
||||
url: string,
|
||||
readyTestId: string,
|
||||
{ attempts = 5, perAttemptTimeoutMs = 8000 } = {},
|
||||
): Promise<void> {
|
||||
// Enable e2e-only test hooks (e.g. the flamegraph span→rect map in
|
||||
// useFlamegraphTestHook) before the first navigation. Registered here because
|
||||
// every trace-detail spec loads the page through this helper, so the flag is
|
||||
// set without a dedicated fixture. Guarded to once per Page — addInitScript
|
||||
// re-runs on every navigation, so re-registering would stack duplicates.
|
||||
if (!e2eHookRegistered.has(page)) {
|
||||
await page.addInitScript(() => {
|
||||
(window as unknown as { __SIGNOZ_E2E__?: boolean }).__SIGNOZ_E2E__ = true;
|
||||
});
|
||||
// Dock the left nav so it doesn't fly out on hover and overlay the trace
|
||||
// content's left strip (which otherwise makes left-edge hover/click targets
|
||||
// land on the sidebar). Once per Page, before the first navigation.
|
||||
await pinSidenav(page);
|
||||
e2eHookRegistered.add(page);
|
||||
}
|
||||
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.goto(url);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page
|
||||
.getByTestId(readyTestId)
|
||||
.waitFor({ state: 'visible', timeout: perAttemptTimeoutMs });
|
||||
return;
|
||||
} catch {
|
||||
// not loaded yet (NoData / seed lag) — reload and retry
|
||||
}
|
||||
}
|
||||
// final navigation so the test's own assertion surfaces a clear failure
|
||||
await page.goto(url);
|
||||
}
|
||||
|
||||
// ── Trace options menu ─────────────────────────────────────────────────────
|
||||
|
||||
// Change the colour-by field via the trace options menu (Trace options → Colour
|
||||
// by → field). colour-by is a per-user preference that persists, so tests should
|
||||
// set a known field explicitly rather than assume the default. `fieldName` is a
|
||||
// COLOR_BY_OPTIONS label (service.name | service.namespace | host.name |
|
||||
// k8s.node.name | k8s.container.name); exact match avoids service.name matching
|
||||
// service.namespace.
|
||||
export async function changeColourByViaMenu(
|
||||
page: Page,
|
||||
fieldName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Trace options' }).click();
|
||||
await page.getByRole('menuitem', { name: /colour by/i }).click();
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: fieldName, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
// ── Large trace fixture (tests/e2e/testdata/traces/large-trace.json) ─────────
|
||||
// One deep, realistic trace: 100 spans across 18 services, nested ~34 levels,
|
||||
// 8 error spans, a wide duration spread, and db/http/llm/messaging attributes —
|
||||
// enough to drive the flamegraph, waterfall, filters and drawer off one seed.
|
||||
// Converted once from a real getWaterfallV4 capture. `loadLargeTrace()` stamps
|
||||
// fresh ids per run (parallel isolation), rebases the timeline to ~now, and
|
||||
// derives landmark span ids so specs target rows without hardcoding ids.
|
||||
|
||||
// Shape of each record in large-trace.json.
|
||||
interface LargeTraceRecord {
|
||||
span_id: string;
|
||||
parent_span_id: string; // empty = root
|
||||
name: string;
|
||||
kind: number;
|
||||
status_code: number;
|
||||
duration: string; // ISO-8601, e.g. "PT0.080000S"
|
||||
offset_ms: number; // start offset from the root span
|
||||
resources: Record<string, string>;
|
||||
attributes: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const LARGE_TRACE_RECORDS = largeTraceRecords as LargeTraceRecord[];
|
||||
|
||||
export interface LargeTrace {
|
||||
traceId: string;
|
||||
spans: SeederSpan[];
|
||||
// landmark span ids — already stamped — for targeting rows / the drawer
|
||||
landmarks: {
|
||||
root: string;
|
||||
errors: string[];
|
||||
db: string;
|
||||
http: string;
|
||||
llm: string;
|
||||
messaging: string;
|
||||
deepLeaf: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Depth of a record via its parent chain (the JSON doesn't store level).
|
||||
function recordDepth(
|
||||
rec: LargeTraceRecord,
|
||||
byId: Map<string, LargeTraceRecord>,
|
||||
): number {
|
||||
let depth = 0;
|
||||
let cur: LargeTraceRecord | undefined = rec;
|
||||
while (cur && cur.parent_span_id) {
|
||||
cur = byId.get(cur.parent_span_id);
|
||||
depth += 1;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
// Build a seedable copy of the large trace with fresh, isolated ids.
|
||||
export function loadLargeTrace(): LargeTrace {
|
||||
const traceId = randomTraceId();
|
||||
// Stamp a fresh span id for every original id, preserving the tree links.
|
||||
const idMap = new Map<string, string>();
|
||||
LARGE_TRACE_RECORDS.forEach((r) => idMap.set(r.span_id, randomSpanId()));
|
||||
|
||||
// Sit the whole trace ~1 min in the past so all timestamps stay <= now.
|
||||
const baseStartMs = Date.now() - 60_000;
|
||||
|
||||
const spans: SeederSpan[] = LARGE_TRACE_RECORDS.map((r) => {
|
||||
const span: SeederSpan = {
|
||||
timestamp: new Date(baseStartMs + r.offset_ms).toISOString(),
|
||||
trace_id: traceId,
|
||||
span_id: idMap.get(r.span_id) as string,
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
status_code: r.status_code,
|
||||
duration: r.duration,
|
||||
resources: r.resources,
|
||||
attributes: r.attributes,
|
||||
};
|
||||
if (r.parent_span_id) {
|
||||
span.parent_span_id = idMap.get(r.parent_span_id);
|
||||
}
|
||||
return span;
|
||||
});
|
||||
|
||||
const byId = new Map(LARGE_TRACE_RECORDS.map((r) => [r.span_id, r]));
|
||||
const stamp = (r: LargeTraceRecord | undefined): string =>
|
||||
r ? (idMap.get(r.span_id) as string) : '';
|
||||
const firstWithAttr = (key: string): LargeTraceRecord | undefined =>
|
||||
LARGE_TRACE_RECORDS.find((r) => key in r.attributes);
|
||||
|
||||
const deepest = LARGE_TRACE_RECORDS.reduce((a, b) =>
|
||||
recordDepth(b, byId) > recordDepth(a, byId) ? b : a,
|
||||
);
|
||||
|
||||
const landmarks = {
|
||||
root: stamp(LARGE_TRACE_RECORDS.find((r) => !r.parent_span_id)),
|
||||
errors: LARGE_TRACE_RECORDS.filter((r) => r.status_code === 2).map((r) =>
|
||||
stamp(r),
|
||||
),
|
||||
db: stamp(firstWithAttr('db.system')),
|
||||
http: stamp(firstWithAttr('http.method')),
|
||||
llm: stamp(firstWithAttr('gen_ai.request.model')),
|
||||
messaging: stamp(firstWithAttr('messaging.system')),
|
||||
deepLeaf: stamp(deepest),
|
||||
};
|
||||
|
||||
return { traceId, spans, landmarks };
|
||||
}
|
||||
|
||||
// ── Flamegraph canvas test hook ──────────────────────────────────────────────
|
||||
// The flamegraph is canvas-rendered, so individual bars have no DOM nodes. The
|
||||
// frontend exposes a read-only span→rect view on window.__sigTraceFlame__
|
||||
// (useFlamegraphTestHook), present only when __SIGNOZ_E2E__ is set — which
|
||||
// gotoTraceUntilLoaded injects via addInitScript.
|
||||
|
||||
// Mirror of the API exposed by useFlamegraphTestHook.
|
||||
interface FlamegraphTestApi {
|
||||
getSpanPoint: (spanId: string) => { x: number; y: number } | null;
|
||||
isSpanInView: (spanId: string) => boolean;
|
||||
getSpanColor: (spanId: string) => string | null;
|
||||
}
|
||||
|
||||
interface FlameWindow {
|
||||
__sigTraceFlame__?: FlamegraphTestApi;
|
||||
}
|
||||
|
||||
// Resolve a span's on-canvas viewport point, waiting through the first paint
|
||||
// (the hook + spanRects populate only after the flamegraph's draw rAF).
|
||||
async function spanPoint(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const handle = await page.waitForFunction(
|
||||
(id) =>
|
||||
(window as unknown as FlameWindow).__sigTraceFlame__?.getSpanPoint(id) ??
|
||||
null,
|
||||
spanId,
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const point = await handle.jsonValue();
|
||||
if (!point) {
|
||||
throw new Error(`flamegraph span "${spanId}" is not drawn on the canvas`);
|
||||
}
|
||||
return point;
|
||||
}
|
||||
|
||||
// Hover the flamegraph bar for `spanId` (opens its SpanHoverCard).
|
||||
export async function hoverFlamegraphSpan(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<void> {
|
||||
const { x, y } = await spanPoint(page, spanId);
|
||||
await page.mouse.move(x, y);
|
||||
}
|
||||
|
||||
// Click the flamegraph bar for `spanId` (selects the span / opens the drawer).
|
||||
export async function clickFlamegraphSpan(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<void> {
|
||||
const { x, y } = await spanPoint(page, spanId);
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.click(x, y);
|
||||
}
|
||||
|
||||
// Whether `spanId`'s bar is currently drawn AND inside the viewport container.
|
||||
export async function isFlamegraphSpanInView(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
(id) =>
|
||||
(window as unknown as FlameWindow).__sigTraceFlame__?.isSpanInView(id) ??
|
||||
false,
|
||||
spanId,
|
||||
);
|
||||
}
|
||||
|
||||
// Resting group color of a span's bar — used to assert colour-by recolor.
|
||||
export async function getFlamegraphSpanColor(
|
||||
page: Page,
|
||||
spanId: string,
|
||||
): Promise<string | null> {
|
||||
return page.evaluate(
|
||||
(id) =>
|
||||
(window as unknown as FlameWindow).__sigTraceFlame__?.getSpanColor(id) ??
|
||||
null,
|
||||
spanId,
|
||||
);
|
||||
}
|
||||
|
||||
// ── User preferences (server-side, per-user) ─────────────────────────────────
|
||||
|
||||
// Trace-detail user-preference keys (mirror frontend constants/userPreferences.ts).
|
||||
export const TRACE_PREFERENCE = {
|
||||
COLOR_BY: 'span_details_color_by_attribute',
|
||||
PREVIEW_FIELDS: 'span_details_preview_attributes',
|
||||
PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
|
||||
} as const;
|
||||
|
||||
// Whether the left nav is docked/pinned (mirror USER_PREFERENCES.SIDENAV_PINNED).
|
||||
const SIDENAV_PINNED = 'sidenav_pinned';
|
||||
|
||||
// A telemetry field key as persisted in the preview-fields preference. Only
|
||||
// `name` is required by the store (derivePreviewFields), but fieldContext /
|
||||
// fieldDataType match how the UI persists them.
|
||||
export interface PreviewFieldKey {
|
||||
name: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
// PUT a single user preference (server-side, per-user). Call BEFORE navigating
|
||||
// to the trace page so its on-mount preference fetch returns the seeded value.
|
||||
//
|
||||
// NOTE: user preferences are GLOBAL PER USER, not per-test — they persist on the
|
||||
// server for the admin user. Reset them (resetTracePreferences) in afterAll, and
|
||||
// be aware other specs run by the same user in parallel share this state.
|
||||
export async function setUserPreference(
|
||||
page: Page,
|
||||
name: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.put(`/api/v1/user/preferences/${name}`, {
|
||||
data: { value },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/${name} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the flamegraph color-by field. `fieldName` must be one of
|
||||
// COLOR_BY_OPTIONS (service.name | service.namespace | host.name |
|
||||
// k8s.node.name | k8s.container.name); '' falls back to the default.
|
||||
export async function setColorByPreference(
|
||||
page: Page,
|
||||
fieldName: string,
|
||||
): Promise<void> {
|
||||
await setUserPreference(page, TRACE_PREFERENCE.COLOR_BY, fieldName);
|
||||
}
|
||||
|
||||
// Persist the span-details preview fields (shown as rows in the hover card).
|
||||
export async function setPreviewFieldsPreference(
|
||||
page: Page,
|
||||
fields: PreviewFieldKey[],
|
||||
): Promise<void> {
|
||||
await setUserPreference(page, TRACE_PREFERENCE.PREVIEW_FIELDS, fields);
|
||||
}
|
||||
|
||||
// Reset trace-detail prefs to defaults. Run in afterAll so a prefs spec doesn't
|
||||
// leak color-by / preview-field state into other specs for the same user.
|
||||
export async function resetTracePreferences(page: Page): Promise<void> {
|
||||
await setColorByPreference(page, '');
|
||||
await setPreviewFieldsPreference(page, []);
|
||||
}
|
||||
|
||||
// Pin (dock) the left nav. When unpinned it's a collapsed rail that flies out on
|
||||
// hover as an absolute OVERLAY, covering the trace content's left strip — so
|
||||
// hover/click on left-edge targets (the waterfall collapse arrow, flamegraph
|
||||
// bars) lands on the sidebar instead. Pinned, it's a flex child that reserves
|
||||
// layout space, so nothing is occluded. Set before navigating: the server pref
|
||||
// wins over localStorage once preferences load.
|
||||
export async function pinSidenav(page: Page): Promise<void> {
|
||||
await setUserPreference(page, SIDENAV_PINNED, true);
|
||||
}
|
||||
2482
tests/e2e/testdata/traces/large-trace.json
vendored
2482
tests/e2e/testdata/traces/large-trace.json
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
// One shared trace for the whole file, seeded once. Unique ids per run keep this
|
||||
// isolated from other parallel specs; the global teardown clears the traces signal.
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
test.describe('Trace details — span details drawer', () => {
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
// Seed once via a disposable request context — no auth needed (direct
|
||||
// seeder call), and cheaper than spinning up a full browser page.
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authedPage: page }) => {
|
||||
// open the trace, reloading until the waterfall renders (seed→query lag)
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}`,
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-01 the floating drawer can be dragged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
|
||||
await page.getByTestId('dock-mode-dialog').click();
|
||||
|
||||
const handle = page.locator('.floating-panel__drag-handle');
|
||||
await expect(handle).toBeVisible();
|
||||
|
||||
const zero = { x: 0, y: 0, width: 0, height: 0 };
|
||||
const before = (await handle.boundingBox()) ?? zero;
|
||||
// Drag from the left of the header (title area) to avoid the action buttons.
|
||||
const startX = before.x + 30;
|
||||
const startY = before.y + before.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX - 120, startY + 80, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect
|
||||
.poll(async () => Math.round(((await handle.boundingBox()) ?? before).x))
|
||||
.toBeLessThan(Math.round(before.x));
|
||||
});
|
||||
|
||||
test('TC-02 a dock-mode change persists and is restored on reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// §0 prefs-boot, UI-first: switch to floating via the dock-mode UI (which
|
||||
// persists the variant), then reload and confirm it's restored — the drawer
|
||||
// boots floating, not the docked-right default.
|
||||
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
|
||||
await page.getByTestId('dock-mode-dialog').click();
|
||||
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await page.getByTestId(`cell-0-${trace.landmarks.root}`).click();
|
||||
|
||||
await expect(page.locator('.floating-panel__drag-handle')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
changeColourByViaMenu,
|
||||
clickFlamegraphSpan,
|
||||
getFlamegraphSpanColor,
|
||||
gotoTraceUntilLoaded,
|
||||
hoverFlamegraphSpan,
|
||||
isFlamegraphSpanInView,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
setColorByPreference,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
// The flamegraph is canvas-rendered, so individual bars have no DOM nodes. These
|
||||
// specs drive it through the window.__sigTraceFlame__ test hook (enabled by
|
||||
// gotoTraceUntilLoaded) — see helpers/trace-details.ts — which resolves a span's
|
||||
// on-canvas point from the live span→rect map and dispatches real mouse events.
|
||||
//
|
||||
// One shared trace for the file, seeded once. Random ids per run isolate it from
|
||||
// other parallel specs; the global teardown clears the traces signal.
|
||||
//
|
||||
// Colour-by recolor is asserted via the hook's getSpanColor (the resting group
|
||||
// color per bar), since canvas pixels aren't directly assertable.
|
||||
//
|
||||
// Deferred: sampled large trace — sampling needs >100k spans
|
||||
// (FLAMEGRAPH_SPAN_LIMIT), which is the deferred large-trace work.
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
test.describe('Trace details — flamegraph', () => {
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
// TC-04 changes colour-by — a per-user pref. Reset it so it doesn't leak to
|
||||
// other specs (afterAll can't use the test-scoped authedPage fixture).
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
await setColorByPreference(page, '');
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authedPage: page }) => {
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}`,
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-01 hovering an error bar opens its hover card with status/start/duration', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await hoverFlamegraphSpan(page, trace.landmarks.errors[0]);
|
||||
|
||||
// "status: error" only renders in the hover card (not in waterfall rows),
|
||||
// so it proves both that the card opened and that we hovered the right
|
||||
// (error) span — the bar was targeted by id via the span→rect map.
|
||||
await expect(page.getByText('status: error')).toBeVisible();
|
||||
await expect(page.getByText(/start: [\d.]+ ms/)).toBeVisible();
|
||||
await expect(page.getByText(/duration: [\d.]+/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 clicking a bar selects the span, opens the drawer, and syncs the waterfall row', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await clickFlamegraphSpan(page, trace.landmarks.db);
|
||||
|
||||
// selection is reflected in the shared URL state...
|
||||
await expect(page).toHaveURL(new RegExp(`spanId=${trace.landmarks.db}`));
|
||||
// ...the drawer opens (Overview tab is drawer-only)...
|
||||
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
|
||||
// ...and the same span's waterfall row is present (views share selection).
|
||||
await expect(page.getByTestId(`cell-0-${trace.landmarks.db}`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 deep-linking a deeply-nested span scrolls it into view on the flamegraph', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Open pre-pointed at a deep (level ~34) span; useScrollToSpan should
|
||||
// center it, so its bar becomes drawn and inside the viewport container.
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}?spanId=${trace.landmarks.deepLeaf}`,
|
||||
`cell-0-${trace.landmarks.deepLeaf}`,
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(() => isFlamegraphSpanInView(page, trace.landmarks.deepLeaf))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test('TC-04 changing colour-by recolors the flamegraph bars', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// colour-by persists per-user, so set an explicit baseline rather than
|
||||
// assuming the default. Root's color under service.name:
|
||||
await changeColourByViaMenu(page, 'service.name');
|
||||
const colorByService = await getFlamegraphSpanColor(
|
||||
page,
|
||||
trace.landmarks.root,
|
||||
);
|
||||
expect(colorByService).not.toBeNull();
|
||||
|
||||
// Switch to host.name → root groups by a different value → new color.
|
||||
await changeColourByViaMenu(page, 'host.name');
|
||||
await expect
|
||||
.poll(() => getFlamegraphSpanColor(page, trace.landmarks.root))
|
||||
.not.toBe(colorByService);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
// §1 header — the Analytics FloatingPanel. The action cluster (Analytics button
|
||||
// + options menu) only renders once trace data is loaded, which gotoTraceUntilLoaded
|
||||
// guarantees by waiting for the root waterfall row.
|
||||
//
|
||||
// Not covered here: subheader summary (presentational → unit test), colour-by /
|
||||
// options menu / trace-id copy (unit), Noz button (feature-flagged, lives in the
|
||||
// filter bar). Resize is deferred — react-rnd's resize handles have no stable hook.
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
test.describe('Trace details — header analytics panel', () => {
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authedPage: page }) => {
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}`,
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-01 the analytics panel can be dragged by its header', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Analytics' }).click();
|
||||
const panel = page.getByTestId('trace-analytics-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const zero = { x: 0, y: 0, width: 0, height: 0 };
|
||||
const before = (await panel.boundingBox()) ?? zero;
|
||||
const hb =
|
||||
(await page.locator('.floating-panel__drag-handle').boundingBox()) ?? zero;
|
||||
|
||||
// Drag the header left + down.
|
||||
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(hb.x + hb.width / 2 - 120, hb.y + hb.height / 2 + 60, {
|
||||
steps: 8,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
// Panel shifted left.
|
||||
await expect
|
||||
.poll(async () => Math.round(((await panel.boundingBox()) ?? before).x))
|
||||
.toBeLessThan(Math.round(before.x));
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
hoverFlamegraphSpan,
|
||||
loadLargeTrace,
|
||||
resetTracePreferences,
|
||||
seedTracesViaSeeder,
|
||||
setPreviewFieldsPreference,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
// §6 — preview fields. A configured preview field appears as a row in the span
|
||||
// hover card (SpanTooltipContent, testid span-hover-card-preview-<key>). The
|
||||
// waterfall variant is covered at the unit/integration level; this spec keeps
|
||||
// the flamegraph (canvas) case, which can't run in jsdom.
|
||||
//
|
||||
// Preview fields are a server-side, per-user preference, so each test seeds them
|
||||
// via the API before navigating; afterAll resets them so the state doesn't leak
|
||||
// into other specs run by the same admin user.
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
// The db landmark span carries db.system="redis"; seed db.system as a preview
|
||||
// field so its value renders in the hover card.
|
||||
const PREVIEW_FIELD = 'db.system';
|
||||
const PREVIEW_VALUE = 'redis';
|
||||
const PREVIEW_TESTID = `span-hover-card-preview-${PREVIEW_FIELD}`;
|
||||
|
||||
// Skipped wholesale until the flamegraph preview-fields fetch race (FE bug, see
|
||||
// the TC-01 FIXME + sprint task) is fixed — the only case here is that flamegraph
|
||||
// hover test, which can't pass reliably yet. The waterfall variant moved to
|
||||
// unit/integration. Re-enable (and un-fixme TC-01) once the flamegraph
|
||||
// gates/refetches on previewFields.
|
||||
test.describe.skip('Trace details — preview fields in the hover card', () => {
|
||||
// Run serially in one worker: preview fields are a per-user preference, so
|
||||
// the afterAll reset must not race a sibling test still using them on another
|
||||
// worker (which intermittently wiped the preview row mid-test).
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
// Reset prefs to defaults (afterAll can't use the authedPage fixture).
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
await resetTracePreferences(page);
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authedPage: page }) => {
|
||||
// Seed the preview field BEFORE navigating so the on-mount prefs fetch
|
||||
// returns it and the hover card renders the row.
|
||||
// db.system is a span ATTRIBUTE (fieldContext 'attribute', not 'span') —
|
||||
// the flamegraph fetches fields selectively, so the wrong context means
|
||||
// the bar's span wouldn't carry the value and the hover row wouldn't render.
|
||||
await setPreviewFieldsPreference(page, [
|
||||
{ name: PREVIEW_FIELD, fieldContext: 'attribute', fieldDataType: 'string' },
|
||||
]);
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}`,
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
);
|
||||
});
|
||||
|
||||
// FIXME: blocked by a frontend bug — the flamegraph fires its span fetch
|
||||
// (POST /flamegraph) with selectFields = color-by only, before previewFields
|
||||
// syncs into the store, and does NOT refetch when the preference lands. So the
|
||||
// flamegraph span never carries the preview attribute (e.g. db.system) and its
|
||||
// hover card can't render the row. Intermittent (passes only when prefs are
|
||||
// cache-warm before the first fetch). Re-enable once the flamegraph
|
||||
// gates/refetches on previewFields. See sprint task.
|
||||
test.fixme('TC-01 flamegraph hover card shows the configured preview field', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const previewRow = page.getByTestId(PREVIEW_TESTID).first();
|
||||
await expect(async () => {
|
||||
await page.mouse.move(0, 0);
|
||||
await hoverFlamegraphSpan(page, trace.landmarks.db);
|
||||
await expect(previewRow).toBeVisible({ timeout: 1500 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
await expect(previewRow).toContainText(PREVIEW_VALUE);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
} from '../../helpers/trace-details';
|
||||
|
||||
const trace = loadLargeTrace();
|
||||
|
||||
test.describe('Trace details — waterfall', () => {
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const request = await playwright.request.newContext();
|
||||
await seedTracesViaSeeder(request, trace.spans);
|
||||
await request.dispose();
|
||||
});
|
||||
|
||||
test('TC-01 deep-link ?spanId auto-selects the span and opens the drawer', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Open the trace pre-pointed at a specific span via the URL, reloading
|
||||
// until the waterfall renders (seed→query lag).
|
||||
const errorSpan = trace.landmarks.errors[0];
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}?spanId=${errorSpan}`,
|
||||
`cell-0-${errorSpan}`,
|
||||
);
|
||||
|
||||
// the deep-linked span's row renders...
|
||||
await expect(page.getByTestId(`cell-0-${errorSpan}`)).toBeVisible();
|
||||
// ...and it auto-selects → the drawer is open (Overview tab is drawer-only)
|
||||
await expect(page.getByRole('tab', { name: /overview/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 deep-linking a deeply-nested span auto-expands ancestors and scrolls it into view', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// deepLeaf sits ~34 levels down; rendering its row at all proves every
|
||||
// ancestor auto-expanded and the waterfall scrolled it into view.
|
||||
const deep = trace.landmarks.deepLeaf;
|
||||
await gotoTraceUntilLoaded(
|
||||
page,
|
||||
`/trace/${trace.traceId}?spanId=${deep}`,
|
||||
`cell-0-${deep}`,
|
||||
);
|
||||
|
||||
await expect(page.getByTestId(`cell-0-${deep}`)).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`spanId=${deep}`));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user