Compare commits

...

3 Commits

Author SHA1 Message Date
Gaurav Tewari
14961b2936 fix: minor changes 2026-07-23 00:46:24 +05:30
Gaurav Tewari
87a716124e chore: remove comments 2026-07-22 18:20:53 +05:30
Gaurav Tewari
bae9eea1b4 feat(llm-attribute-mapping): add Test tab with sample-span runner
Adds the Test tab to the LLM Observability Attribute Mapping page,
replacing the "Coming soon" placeholder. Users paste a sample JSON span
and run it through their configured mappers to preview which target
attributes get populated and which source key matched, before saving.

- Monaco JSON editor with a SigNoz light/dark theme, inline validation
  and a prefilled sample span
- Result diff view labelling attributes added/changed/unchanged/removed
- Payload sends all draft groups; per-group mappers are sent only when
  they differ from the saved snapshot (null -> backend reuses stored
  mappers), so unsaved edits are testable without persisting
- Expose snapshot from useAttributeMappingEditor; add
  useCanManageAttributeMapping hook

Backed by the span mapper test endpoint (#11795).
2026-07-22 18:19:33 +05:30
18 changed files with 993 additions and 29 deletions

View File

@@ -15,6 +15,18 @@ jest.mock('@signozhq/ui/sonner', () => ({
},
}));
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
// Admin gating on the write controls flows through useAuthZ. Mock it directly
// (synchronous) so the functional tests stay synchronous; the read-only block
// below flips it to deny-all.
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
@@ -102,6 +114,8 @@ describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
// Reset URL state between tests — jsdom shares window.location across a file.
window.history.pushState(null, '', '/');
// Default to an admin; the read-only block overrides this.
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
@@ -507,4 +521,55 @@ describe('AttributeMappingsTab (integration)', () => {
).resolves.toBeInTheDocument();
});
});
// The write APIs (create/update/delete group & mapper) are Admin-only on the
// backend, so a non-admin gets a read-only view: the data renders, but every
// write control is hidden.
describe('read-only (non-admin)', () => {
beforeEach(() => {
mockedUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
});
it('hides the "Add a new group" button', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
expect(screen.queryByTestId('add-group-row')).not.toBeInTheDocument();
});
it('hides the group enable toggle and actions menu', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
expect(
screen.queryByTestId('group-enabled-group-1'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('group-actions-group-1'),
).not.toBeInTheDocument();
});
it('renders mappers read-only: no "Add mapping" button or row actions', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
// The mapper still renders...
await screen.findByTestId('mapper-target-mapper-1');
// ...but every write control is gone.
expect(screen.queryByTestId('add-mapper-group-1')).not.toBeInTheDocument();
expect(
screen.queryByTestId('mapper-enabled-mapper-1'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Mapping actions' }),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,6 +1,7 @@
import { Switch } from '@signozhq/ui/switch';
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
import styles from './GroupHeaderActions.module.scss';
@@ -16,7 +17,11 @@ function GroupHeaderActions({
onToggle,
onEdit,
onRemove,
}: GroupHeaderActionsProps): JSX.Element {
}: GroupHeaderActionsProps): JSX.Element | null {
const canManage = useCanManageAttributeMapping();
if (!canManage) {
return null;
}
return (
<div
className={styles.actions}

View File

@@ -11,6 +11,7 @@ import {
Mapper,
} from 'container/LLMObservability/AttributeMapping/types';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import { COLUMN_COUNT } from '../constants';
import MapperRow from '../MapperRow/MapperRow';
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
@@ -39,6 +40,7 @@ function GroupMappers({
onEditMapper,
}: GroupMappersProps): JSX.Element {
const { hydrateGroupMappers, removeMapper, toggleMapper } = editor;
const canManage = useCanManageAttributeMapping();
const hasServerId = group.serverId !== null;
const { data, isLoading, isError } = useListSpanMappers(
@@ -144,16 +146,20 @@ function GroupMappers({
/>
));
// The add-mapping row trails every non-error state (including loading/empty).
// The add-mapping row trails every non-error state (including loading/empty),
// but only for users who can manage mappings — non-admins get a read-only view.
let rows: JSX.Element[];
if (isErrorMappers) {
rows = [errorRow];
} else if (isLoadingMappers && mapperCount === 0) {
rows = [...skeletonRows, addMapperRow];
rows = [...skeletonRows];
} else if (mapperCount === 0) {
rows = [emptyRow, addMapperRow];
rows = [emptyRow];
} else {
rows = [...mapperRows, addMapperRow];
rows = [...mapperRows];
}
if (canManage && !isErrorMappers) {
rows.push(addMapperRow);
}
return (

View File

@@ -6,6 +6,7 @@ import cx from 'classnames';
import { motion } from 'motion/react';
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
import styles from './MapperRow.module.scss';
@@ -30,6 +31,7 @@ function MapperRow({
onRemove,
onToggle,
}: MapperRowProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
const sources = mapper.sources ?? [];
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
const remainingSources = sources.length - visibleSources.length;
@@ -101,14 +103,16 @@ function MapperRow({
)}
</td>
<td className={cx(styles.cell, styles.actionsCell)}>
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
</div>
{canManage && (
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
</div>
)}
</td>
</motion.tr>
);

View File

@@ -11,6 +11,7 @@ import {
DraftMapper,
} from 'container/LLMObservability/AttributeMapping/types';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import GroupHeader from './GroupHeader/GroupHeader';
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
import GroupMappers from './GroupMappers/GroupMappers';
@@ -33,6 +34,7 @@ function MappingsTable({
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
const drawer = useMapperFormDrawer();
const canManage = useCanManageAttributeMapping();
const { upsertMapper, removeMapper } = editor;
@@ -120,18 +122,20 @@ function MappingsTable({
return (
<div className={styles.tableWrapper}>
<div className={styles.toolbar}>
<Button
variant="link"
color="primary"
prefix={<Plus size={14} />}
onClick={onAddGroup}
testId="add-group-row"
disabled={editor.isLoading}
>
Add a new group
</Button>
</div>
{canManage && (
<div className={styles.toolbar}>
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={onAddGroup}
testId="add-group-row"
disabled={editor.isLoading}
>
Add a new group
</Button>
</div>
)}
{isEmpty ? (
<div className={styles.tableState} data-testid="mapper-groups-empty">

View File

@@ -8,6 +8,7 @@ import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import TestTab from './TestTab/TestTab';
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
@@ -44,9 +45,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
{
key: 'test',
label: 'Test',
disabled: true,
disabledReason: 'Coming soon',
children: null,
children: <TestTab editor={editor} />,
},
];

View File

@@ -0,0 +1,54 @@
.skeleton {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--l1-background);
}
.line {
display: flex;
align-items: center;
height: 18px;
}
.lineNumber {
flex: 0 0 40px;
padding-right: var(--padding-3);
text-align: right;
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
opacity: 0.6;
user-select: none;
}
.lineContent {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
}
.bar {
height: 8px;
border-radius: var(--radius-1);
background: var(--l2-border);
animation: jsonSkeletonPulse 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bar {
animation: none;
}
}
@keyframes jsonSkeletonPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}

View File

@@ -0,0 +1,56 @@
import styles from './JsonEditorSkeleton.module.scss';
interface SkeletonLine {
indent: number;
barWidths: number[];
}
const SKELETON_LINES: SkeletonLine[] = [
{ indent: 0, barWidths: [10] },
{ indent: 1, barWidths: [90, 10] },
{ indent: 2, barWidths: [155, 190] },
{ indent: 2, barWidths: [135, 190] },
{ indent: 2, barWidths: [150, 50] },
{ indent: 2, barWidths: [180, 35] },
{ indent: 2, barWidths: [185, 200] },
{ indent: 1, barWidths: [14] },
{ indent: 1, barWidths: [75, 10] },
{ indent: 2, barWidths: [95, 90] },
{ indent: 2, barWidths: [170, 85] },
{ indent: 1, barWidths: [10] },
{ indent: 0, barWidths: [10] },
];
const INDENT_WIDTH_PX = 14;
function JsonEditorSkeleton(): JSX.Element {
return (
<div
className={styles.skeleton}
data-testid="json-editor-skeleton"
aria-hidden="true"
>
{SKELETON_LINES.map((line, lineIndex) => (
// eslint-disable-next-line react/no-array-index-key
<div key={lineIndex} className={styles.line}>
<span className={styles.lineNumber}>{lineIndex + 1}</span>
<span
className={styles.lineContent}
style={{ paddingLeft: line.indent * INDENT_WIDTH_PX }}
>
{line.barWidths.map((width, barIndex) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={barIndex}
className={styles.bar}
style={{ width }}
/>
))}
</span>
</div>
))}
</div>
);
}
export default JsonEditorSkeleton;

View File

@@ -0,0 +1,121 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { useMemo } from 'react';
import {
AttrChangeStatus,
AttrDiffEntry,
diffAttributeMaps,
formatAttributeValue,
} from './testPayload';
import styles from './TestTab.module.scss';
import { TestTabAttributes, TestTabResource } from './useTestSpanMapper';
interface TestResultProps {
index: number;
span: SpantypesSpanMapperTestSpanDTO;
inputAttributes: TestTabAttributes;
inputResource: TestTabResource;
}
const STATUS_BADGE: Partial<
Record<
AttrChangeStatus,
{ color: 'success' | 'robin' | 'sienna'; label: string }
>
> = {
added: { color: 'success', label: 'populated' },
changed: { color: 'robin', label: 'remapped' },
removed: { color: 'sienna', label: 'moved out' },
};
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
added: styles.added,
removed: styles.removed,
};
interface ResultSection {
key: string;
title: string;
entries: AttrDiffEntry[];
}
function TestResult({
index,
span,
inputAttributes,
inputResource,
}: TestResultProps): JSX.Element {
const attributeEntries = useMemo(
() => diffAttributeMaps(inputAttributes, span.attributes ?? {}),
[inputAttributes, span.attributes],
);
const resourceEntries = useMemo(
() => diffAttributeMaps(inputResource, span.resource ?? {}),
[inputResource, span.resource],
);
const sections: ResultSection[] = [
{
key: 'attributes',
title: 'Resulting attributes',
entries: attributeEntries,
},
];
if (resourceEntries.length > 0) {
sections.push({
key: 'resource',
title: 'Resulting resource',
entries: resourceEntries,
});
}
return (
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
{sections.map((section) => (
<div
key={section.key}
className={styles.resultSection}
data-testid={`test-result-${index}-${section.key}`}
>
<div className={styles.resultTitle}>{section.title}</div>
{section.entries.length === 0 ? (
<div className={styles.resultEmpty}>No keys in this map.</div>
) : (
<div className={styles.attrRows}>
{section.entries.map((entry) => {
const badge = STATUS_BADGE[entry.status];
return (
<div
key={entry.key}
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
>
<span className={styles.attrKey} title={entry.key}>
{entry.key}
</span>
<span
className={styles.attrValue}
title={formatAttributeValue(entry.value)}
>
{formatAttributeValue(entry.value)}
</span>
{badge ? (
<Badge color={badge.color} variant="outline">
{badge.label}
</Badge>
) : (
<span />
)}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
export default TestResult;

View File

@@ -0,0 +1,171 @@
.testTab {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
margin-top: var(--spacing-6);
}
// Heading + description on the left, Run button pinned to the top-right.
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-6);
}
.headerText {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.description {
margin: 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
height: calc(100vh - 320px);
min-height: 320px;
}
.editor {
width: 100%;
height: 100%;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}
.validationCallout {
width: 100%;
}
// No top padding so results align with the editor's first line, which sits
// flush against the top of its own panel.
.resultsPanel {
height: 100%;
overflow-y: auto;
padding: var(--padding-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
}
// Pre-run empty state that fills the blank right half.
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
height: 100%;
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.placeholderTitle {
font-weight: var(--font-weight-semibold);
color: var(--l2-foreground);
}
.error {
padding: var(--padding-3) var(--padding-4);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.resultCard {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
border-radius: var(--radius-2);
background: var(--l1-background);
}
.resultSection {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.resultTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.resultEmpty {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.attrRows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.attrRow {
display: grid;
grid-template-columns: clamp(200px, 40%, 340px) minmax(0, 1fr) auto;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--padding-3);
border-radius: var(--radius-2);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
&.added {
background: var(--callout-success-background);
}
&.removed {
opacity: 0.65;
.attrKey,
.attrValue {
text-decoration: line-through;
}
}
}
.attrKey,
.attrValue {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attrKey {
font-weight: var(--font-weight-medium);
}
.attrValue {
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,148 @@
import MEditor from '@monaco-editor/react';
import { Play } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { useIsDarkMode } from 'hooks/useDarkMode';
import styles from './TestTab.module.scss';
import JsonEditorSkeleton from './JsonEditorSkeleton';
import TestResult from './TestResult';
import { AttributeMappingEditor } from '../hooks/useAttributeMappingEditor';
import {
defineSignozJsonTheme,
SIGNOZ_JSON_THEME_DARK,
SIGNOZ_JSON_THEME_LIGHT,
} from './jsonEditorTheme';
import { useTestSpanMapper } from './useTestSpanMapper';
interface TestTabProps {
editor: AttributeMappingEditor;
}
function TestTab({ editor }: TestTabProps): JSX.Element {
const {
input,
setInput,
run,
isRunning,
result,
testedAttributes,
testedResource,
error,
validationError,
} = useTestSpanMapper(editor.snapshot, editor.groups);
const isDarkMode = useIsDarkMode();
function renderResults(): JSX.Element {
if (error) {
return (
<div className={styles.error} role="alert" data-testid="test-error">
{error}
</div>
);
}
if (result) {
if (result.length === 0) {
return (
<div className={styles.resultEmpty} data-testid="test-results-empty">
No spans returned. The mappers produced no output for this input.
</div>
);
}
return (
<div className={styles.results} data-testid="test-results">
{result.map((span, index) => (
<TestResult
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
span={span}
inputAttributes={testedAttributes ?? {}}
inputResource={testedResource ?? {}}
/>
))}
</div>
);
}
return (
<div className={styles.placeholder} data-testid="test-results-placeholder">
<span className={styles.placeholderTitle}>No results yet</span>
<span>
Run the test to see which target attributes your mappers populate.
</span>
</div>
);
}
return (
<div className={styles.testTab} data-testid="test-tab">
<div className={styles.header}>
<div className={styles.headerText}>
<h3 className={styles.heading}>Test with sample span</h3>
<p className={styles.description}>
Paste a JSON span object to see which target attributes get populated and
which source key matched.
</p>
</div>
<Button
testId="run-test-button"
variant="solid"
color="primary"
prefix={<Play size={14} />}
onClick={run}
loading={isRunning}
disabled={isRunning || validationError !== null}
>
Run Test
</Button>
</div>
<div className={styles.body}>
<div
className={styles.editor}
data-testid="test-span-input"
aria-label="Sample span JSON"
>
<MEditor
language="json"
value={input}
onChange={(value): void => setInput(value ?? '')}
height="100%"
loading={<JsonEditorSkeleton />}
theme={isDarkMode ? SIGNOZ_JSON_THEME_DARK : SIGNOZ_JSON_THEME_LIGHT}
beforeMount={defineSignozJsonTheme}
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
formatOnPaste: true,
tabSize: 2,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
</div>
<div className={styles.resultsPanel} data-testid="test-results-panel">
{renderResults()}
</div>
</div>
{validationError && (
<Callout
type="error"
size="small"
showIcon
title={validationError}
testId="test-input-error"
className={styles.validationCallout}
/>
)}
</div>
);
}
export default TestTab;

View File

@@ -0,0 +1,38 @@
//TODO: This is a local theme for now, but ideally for the editor as well. Just like prettyViewer, we have a theme. We should have a theme for the editor as well.
import { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
export const SIGNOZ_JSON_THEME_DARK = 'signoz-attr-mapping-json-dark';
export const SIGNOZ_JSON_THEME_LIGHT = 'signoz-attr-mapping-json-light';
const SHARED_THEME = {
inherit: true,
colors: {
'editor.background': '#00000000', // transparent — inherit the panel bg
},
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
fontSize: 12,
fontWeight: 'normal',
lineHeight: 18,
letterSpacing: -0.06,
};
export function defineSignozJsonTheme(monaco: Monaco): void {
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_DARK, {
...SHARED_THEME,
base: 'vs-dark',
rules: [
{ token: 'string.key.json', foreground: Color.BG_ROBIN_400 },
{ token: 'string.value.json', foreground: Color.BG_VANILLA_400 },
],
});
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_LIGHT, {
...SHARED_THEME,
base: 'vs',
rules: [
{ token: 'string.key.json', foreground: Color.BG_ROBIN_500 },
{ token: 'string.value.json', foreground: Color.BG_INK_400 },
],
});
}

View File

@@ -0,0 +1,145 @@
import {
SpantypesPostableSpanMapperTestDTO,
SpantypesPostableSpanMapperTestGroupDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import { DraftGroup } from '../types';
import {
buildPostableGroup,
buildPostableMapper,
groupDraftFromNode,
mapperDraftFromNode,
} from '../utils';
function shouldSendMappers(
snap: DraftGroup | undefined,
group: DraftGroup,
): boolean {
if (!snap) {
return true;
}
return !isEqual(snap.mappers, group.mappers);
}
export function buildTestGroups(
snapshot: DraftGroup[],
draft: DraftGroup[],
): SpantypesPostableSpanMapperTestGroupDTO[] {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
return draft.map((group) => {
const base = buildPostableGroup(groupDraftFromNode(group));
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
return {
...base,
mappers: shouldSendMappers(snap, group)
? group.mappers.map((mapper) =>
buildPostableMapper(mapperDraftFromNode(mapper)),
)
: null,
};
});
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
const keys = Object.keys(parsed);
return (
keys.length > 0 &&
keys.every((key) => key === 'attributes' || key === 'resource') &&
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
);
}
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('Paste a JSON span object to run the test.');
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error(
'Invalid JSON — check for trailing commas or missing quotes.',
);
}
if (!isPlainObject(parsed)) {
throw new Error('Span must be a JSON object of attribute key-value pairs.');
}
if (isSpanEnvelope(parsed)) {
return {
attributes: isPlainObject(parsed.attributes) ? parsed.attributes : {},
resource: isPlainObject(parsed.resource) ? parsed.resource : {},
};
}
return { attributes: parsed, resource: {} };
}
export function buildTestRequest(
snapshot: DraftGroup[],
draft: DraftGroup[],
input: string,
): SpantypesPostableSpanMapperTestDTO {
return {
groups: buildTestGroups(snapshot, draft),
spans: [parseSpanInput(input)],
};
}
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
export interface AttrDiffEntry {
key: string;
value: unknown;
status: AttrChangeStatus;
}
// Renders a diff value for display: strings as-is, everything else JSON-encoded.
export function formatAttributeValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
export function diffAttributeMaps(
inputAttributes: Record<string, unknown>,
resultAttributes: Record<string, unknown>,
): AttrDiffEntry[] {
const added: AttrDiffEntry[] = [];
const changed: AttrDiffEntry[] = [];
const unchanged: AttrDiffEntry[] = [];
const removed: AttrDiffEntry[] = [];
Object.entries(resultAttributes).forEach(([key, value]) => {
if (!(key in inputAttributes)) {
added.push({ key, value, status: 'added' });
} else if (!isEqual(inputAttributes[key], value)) {
changed.push({ key, value, status: 'changed' });
} else {
unchanged.push({ key, value, status: 'unchanged' });
}
});
Object.entries(inputAttributes).forEach(([key, value]) => {
if (!(key in resultAttributes)) {
removed.push({ key, value, status: 'removed' });
}
});
return [...added, ...changed, ...unchanged, ...removed];
}

View File

@@ -0,0 +1,127 @@
import { useCallback, useMemo, useState } from 'react';
import {
RenderErrorResponseDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
import { AxiosError } from 'axios';
import { buildTestRequest, parseSpanInput } from './testPayload';
import { DraftGroup } from '../types';
export type TestTabAttributes = Record<string, unknown>;
export type TestTabResource = Record<string, unknown>;
export const SAMPLE_SPAN_JSON = `{
"attributes": {
"my_company.llm.input": "What is quantum computing?",
"llm.input_messages": "What is quantum computing?",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.total_tokens": 1250,
"gen_ai.content.completion": "Quantum computing leverages..."
},
"resource": {
"service.name": "llm-gateway",
"deployment.environment": "production"
}
}`;
function apiErrorMessage(error: unknown): string {
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
return (
axiosError?.response?.data?.error?.message ??
(error instanceof Error ? error.message : 'Test failed. Please try again.')
);
}
export interface UseTestSpanMapper {
input: string;
setInput: (value: string) => void;
run: () => void;
reset: () => void;
isRunning: boolean;
validationError: string | null;
result: SpantypesSpanMapperTestSpanDTO[] | null;
testedAttributes: TestTabAttributes | null;
testedResource: TestTabResource | null;
error: string | null;
}
export function useTestSpanMapper(
snapshot: DraftGroup[],
draft: DraftGroup[],
): UseTestSpanMapper {
const [input, setInput] = useState<string>(SAMPLE_SPAN_JSON);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
null,
);
const [testedAttributes, setTestedAttributes] =
useState<TestTabAttributes | null>(null);
const [testedResource, setTestedResource] = useState<TestTabResource | null>(
null,
);
const { mutate, isLoading } = useTestSpanMappers();
const validationError = useMemo((): string | null => {
try {
parseSpanInput(input);
return null;
} catch (err) {
return err instanceof Error ? err.message : 'Invalid span JSON.';
}
}, [input]);
const reset = useCallback((): void => {
setError(null);
setResult(null);
setTestedAttributes(null);
setTestedResource(null);
}, []);
const run = useCallback((): void => {
reset();
let body;
try {
body = buildTestRequest(snapshot, draft, input);
} catch (parseError) {
setError(apiErrorMessage(parseError));
return;
}
const submittedSpan = body.spans?.[0];
const submittedAttributes = (submittedSpan?.attributes ??
{}) as TestTabAttributes;
const submittedResource = (submittedSpan?.resource ?? {}) as TestTabResource;
mutate(
{ data: body },
{
onSuccess: (response) => {
setTestedAttributes(submittedAttributes);
setTestedResource(submittedResource);
setResult(response.data?.spans ?? []);
},
onError: (mutationError) => {
setResult(null);
setError(apiErrorMessage(mutationError));
},
},
);
}, [snapshot, draft, input, mutate, reset]);
return {
input,
setInput,
run,
reset,
isRunning: isLoading,
validationError,
result,
testedAttributes,
testedResource,
error,
};
}

View File

@@ -1,6 +1,13 @@
import { rest, server } from 'mocks-server/server';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The header's Save/Discard and the group toggle are Admin-gated via useAuthZ;
// render as an admin so those controls are present.
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
@@ -16,6 +23,7 @@ describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupGroups();
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {

View File

@@ -1,6 +1,7 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
@@ -16,12 +17,13 @@ function AttributeMappingHeader({
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
return (
<header className={styles.pageHeader}>
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{isDirty && (
{canManage && isDirty && (
<div className={styles.pageHeaderActions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes

View File

@@ -35,6 +35,7 @@ function clone(groups: DraftGroup[]): DraftGroup[] {
export interface AttributeMappingEditor {
groups: DraftGroup[];
snapshot: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
@@ -304,6 +305,7 @@ export function useAttributeMappingEditor(): AttributeMappingEditor {
return {
groups: draft ?? [],
snapshot,
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,

View File

@@ -0,0 +1,9 @@
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
const ADMIN_PERMISSION = [IsAdminPermission];
export function useCanManageAttributeMapping(): boolean {
const { allowed } = useAuthZ(ADMIN_PERMISSION);
return allowed;
}