Compare commits

...

13 Commits

Author SHA1 Message Date
Gaurav Tewari
cae2a76b6a chore: sync with main 2026-10-16 11:25:46 +05:30
Nityananda Gohain
60bc6ac96f feat(tracedetail): format raw Anthropic and Gemini bodies, skip embeddings (#13141)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Anthropic Messages: formats a request `{system, messages}` or a
response `{content, stop_reason}`, with every image and document source
type, `tool_use`, `tool_result`, thinking and the server tool blocks.
- Gemini generateContent: formats a request `{contents,
systemInstruction}` or a response `{candidates}`, in REST camelCase or
SDK snake_case, with inline and file data, thought parts, `functionCall`
and `functionResponse`.
- Tool results that both APIs carry inside the next user turn become a
tool message before it. The Anthropic stop reasons and Gemini finish
reasons map onto the schema's enum.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Eighth and last of the stack, on top of #13115. No capture records
either body, OpenInference's flattened messages cover its users; every
case cites the SDK type or API doc behind it. These serve gateways
recording raw provider bodies and the fallback path.
2026-10-15 19:58:59 +00:00
Nityananda Gohain
8c4db94ef7 feat(tracedetail): format OpenRouter answers and Vercel AI SDK prompts (#13139)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- OpenRouter: formats the `{completion, reasoning, tools, rawRequest}`
answer object its broadcast writes, the completion as a tool call when
it is a tool name from `tools`
followed by JSON arguments, else as text; beside the OpenAI chat request
it answers this counts as one format.
- Vercel AI SDK and Mastra: formats the legacy `ai.*` telemetry, the
provider prompt in its v1 and v2 part spellings, the parent span's
`ai.prompt`, and the answer split across
`ai.response.text` and `ai.response.toolCalls`, with images given as a
URL, base64 or a serialised Buffer.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
  Seventh of the stack, on top of #13115.
<!--Please delete paragraphs that you did not use before submitting.-->
2026-10-15 19:51:52 +00:00
Nityananda Gohain
1dbc9ed47c feat(tracedetail): format LangChain serialised runs (#13137)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Formats the LangSmith OTel exporter's serialised run inputs and
outputs, `{messages: [[…]]}` and `{generations: [[…]]}` of
langchain_core `{lc, type, id, kwargs}` messages: roles from the message
class, string content and langchain_core's standard content blocks, tool
calls and tool messages,
  and `error` for a failed call whose body sits in `response_metadata`.
- The finish reason is read under the key each partner writes:
`finish_reason` for most, `stop_reason` for anthropic, `stopReason` for
bedrock, `done_reason` for ollama. Provider-native thinking blocks are
kept as sent for now.


<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
  Sixth of the stack, on top of #13115.
2026-10-15 19:51:42 +00:00
Vikrant Gupta
65ad9b5f38 feat(statsreporter): open the stats endpoint to every authenticated caller (#13152)
#### Description

- `GET /api/v1/stats` drops the viewer role gate and moves to open
access. Authentication still applies; the handler reads the organization
from the caller's claims, so the response stays scoped to their org.
2026-10-15 19:44:15 +00:00
Nityananda Gohain
de1912de43 feat(tracedetail): format OpenAI Responses bodies (#13136)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Formats raw OpenAI Responses bodies, a request `{input, instructions}`
or a response `{output, status}` of items: messages, function and custom
tool calls with their outputs, reasoning summaries, compaction, and
every built-in tool item as a server tool call or its response.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Fifth of the stack, on top of #13115. No capture reaches this converter
directly, OpenInference's flattened messages cover the Agents SDK; it
serves gateways recording Responses bodies and the fallback path.

<!--Please delete paragraphs that you did not use before submitting.-->
2026-10-15 18:46:51 +00:00
Nityananda Gohain
81ff25b2f8 feat(tracedetail): format openllmetry legacy prompt and completion keys (#13135)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Formats the flattened `gen_ai.prompt.N.*` and `gen_ai.completion.N.*`
keys openllmetry's instrumentations wrote before their semconv rewrite,
still written by mistralai and by crewai through the openai
instrumentation 0.45.6.
- A semconv output message with no parts takes its tool call from the
`gen_ai.completion.N` keys beside it, which litellm writes while its
semconv message never carries the call.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
  Fourth of the stack, on top of #13115.

<!--Please delete paragraphs that you did not use before submitting.-->
2026-10-15 18:30:03 +00:00
Nityananda Gohain
923d758f7c feat(tracedetail): format OpenInference flattened messages (#13133)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- Formats the flattened `llm.input_messages.N.*` and
`llm.output_messages.N.*` keys OpenInference writes on LLM spans. They
take precedence over the raw provider request the mapper copies, since
they have one shape across providers; a side without them falls back to
the raw copy.
- Handles text, media and reasoning items, tool calls and results, the
legacy `function_call` keys and `llm.finish_reason`; runs of assistant
messages fold into one.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
Third of the stack, on top of #13115. Thread integration suite passes
end to end for all three.
2026-10-15 18:29:26 +00:00
Nityananda Gohain
eeb57ceff2 feat(tracedetail): format gen_ai system instructions and finish reasons (#13131)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- `gen_ai.system_instructions` leads the formatted input as a system
message; `gen_ai.response.finish_reasons` sets the finish reason of each
output message over whatever the payload carried, whichever converter
ran.
- Semconv repairs for pydantic-ai: tool call arguments given as a JSON
string are decoded, `result` on a tool call response becomes `response`.
Finish reasons inside semconv messages go through the alias table,
litellm writes `tool_calls`.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/nerve-pod/issues/266

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
  Second of the stack, on top of #13115.

<!--Please delete paragraphs that you did not use before submitting.-->
2026-10-15 18:28:35 +00:00
Ashwin Bhatkal
d28cd7cf2e ci(e2e): run e2e on every PR that changes the frontend (#13147)
#### Description

- e2e now runs on every PR that changes `frontend/` or `tests/e2e/`. The
`safe-to-e2e` label still opts in any other PR.
- A new `changes` job reads the PR's file list and gates the `fmtlint`
and `test` jobs on it.
- A new `e2e` job is the single check to mark as required. It fails when
e2e was needed but did not pass, including fork and dependabot PRs still
waiting on `safe-to-test`.
- In the merge queue, `e2e` passes without running the suite, so the
queue is not blocked.
- New pushes cancel the PR's in-flight run.
2026-10-15 18:26:10 +00:00
Gaurav Tewari
dc01e3a4a1 chore: change order 2026-10-15 11:14:53 +05:30
Gaurav Tewari
56e849ba53 fix: resolve tooltip overflow 2026-10-15 11:14:53 +05:30
Gaurav Tewari
a65e34d799 feat(trace-details): waterfall tooltip redesign and AI span badges 2026-10-15 11:14:53 +05:30
60 changed files with 3892 additions and 156 deletions

View File

@@ -3,16 +3,44 @@ name: e2eci
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
pull_request_target:
types:
- labeled
merge_group:
types:
- checks_requested
concurrency:
group: e2eci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
if: github.event_name != 'merge_group'
runs-on: ubuntu-latest
outputs:
e2e: ${{ steps.changes.outputs.e2e }}
steps:
- name: changes
id: changes
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
run: |
files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename')
if [ "$LABELED" = true ] || grep -qE '^(frontend|tests/e2e)/' <<< "$files"; then
echo "e2e=true" >> "$GITHUB_OUTPUT"
fi
fmtlint:
needs: changes
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
steps:
- name: checkout
@@ -41,9 +69,10 @@ jobs:
project:
- chromium
shard: [1, 2, 3, 4]
needs: changes
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -152,3 +181,17 @@ jobs:
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
path: tests/e2e/artifacts/
retention-days: 5
# Required status check. Fails when e2e was needed but did not pass,
# including fork and dependabot PRs still waiting on safe-to-test.
e2e:
if: always()
needs: [changes, fmtlint, test]
runs-on: ubuntu-latest
steps:
- name: verdict
if: |
github.event_name != 'merge_group' && (needs.changes.result != 'success' ||
(needs.changes.outputs.e2e == 'true' && (needs.fmtlint.result != 'success' || needs.test.result != 'success')))
run: |
echo "::error::e2e did not pass. Fork and dependabot PRs need the safe-to-test label."
exit 1

View File

@@ -16064,10 +16064,8 @@ paths:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
- api_key: []
- tokenizer: []
summary: Get stats
tags:
- stats

View File

@@ -9,12 +9,14 @@ describe('ErrorResponseHandler', () => {
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
});
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toStrictEqual(
{
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
},
);
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();

View File

@@ -1,11 +1,11 @@
import { ReactNode } from 'react';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import styles from './EntityMetadataItem.module.scss';
interface EntityMetadataItemProps {
tooltip: string;
tooltip: TooltipProps['title'];
icon?: ReactNode;
children: ReactNode;
}

View File

@@ -8,3 +8,12 @@
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
--typography-font-size: var(--periscope-font-size-base);
}
.tokenTooltip {
composes: tooltipBody from '../shared.module.scss';
min-width: 180px;
}
.tokenTooltipTitle {
padding-bottom: var(--spacing-4);
}

View File

@@ -1,10 +1,18 @@
import { CalendarClock, Server, Timer } from '@signozhq/icons';
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import {
AiTokenCounts,
formatCost,
formatTokens,
getTotalInputTokens,
hasValue,
} from '../utils/aiTraceUtils';
import EntityMetadataItem from './EntityMetadataItem';
import TokenUsageTooltip from './TokenUsageTooltip';
import styles from './EntityMetadataRow.module.scss';
@@ -16,6 +24,8 @@ interface EntityMetadataRowProps {
execTimePercent?: number;
timestamp?: string;
statusCode?: string | number;
tokens?: AiTokenCounts;
cost?: number | null;
}
const ICON_SIZE = 14;
@@ -32,8 +42,11 @@ function EntityMetadataRow({
execTimePercent,
timestamp,
statusCode,
tokens,
cost,
}: EntityMetadataRowProps): JSX.Element {
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
const totalInput = tokens && getTotalInputTokens(tokens);
const durationTooltip =
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
// Single source of duration formatting so both rows label units identically.
@@ -91,6 +104,24 @@ function EntityMetadataRow({
<HttpStatusBadge statusCode={statusCode} />
</EntityMetadataItem>
)}
{tokens && (
<EntityMetadataItem
tooltip={<TokenUsageTooltip tokens={tokens} />}
icon={<Coins size={ICON_SIZE} />}
>
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
</EntityMetadataItem>
)}
{hasValue(cost) && (
<EntityMetadataItem
tooltip="Total cost"
icon={<Landmark size={ICON_SIZE} />}
>
Cost — {formatCost(cost)}
</EntityMetadataItem>
)}
</div>
);
}
@@ -102,6 +133,8 @@ EntityMetadataRow.defaultProps = {
execTimePercent: undefined,
timestamp: undefined,
statusCode: undefined,
tokens: undefined,
cost: undefined,
};
export default EntityMetadataRow;

View File

@@ -0,0 +1,35 @@
import { Typography } from '@signozhq/ui/typography';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import { AiTokenCounts } from '../utils/aiTraceUtils';
import styles from './EntityMetadataRow.module.scss';
interface TokenUsageTooltipProps {
tokens: AiTokenCounts;
}
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
return (
<div className={styles.tokenTooltip}>
<Typography.Text
size="small"
weight="normal"
className={styles.tokenTooltipTitle}
>
Usage Breakdown
</Typography.Text>
<TokenBreakdown
input={input}
output={output}
cacheRead={cacheRead}
cacheWrite={cacheWrite}
reasoning={reasoning}
/>
</div>
);
}
export default TokenUsageTooltip;

View File

@@ -1,9 +1,11 @@
import { useMemo } from 'react';
import { Link2 } from '@signozhq/icons';
import dayjs from 'dayjs';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
LinkedSpansPanel,
@@ -31,6 +33,10 @@ function SpanSummary({
}: SpanSummaryProps): JSX.Element {
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
const aiUsage = useMemo(
() => getAIUsageDetails(selectedSpan)?.usage,
[selectedSpan],
);
return (
<>
@@ -63,6 +69,8 @@ function SpanSummary({
: undefined
}
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
tokens={aiUsage && convertToAiTokens(aiUsage)}
cost={aiUsage?.cost}
/>
<div className={styles.spanInfoItem}>

View File

@@ -95,4 +95,26 @@ describe('SpanSummary', () => {
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
});
it('renders tokens and cost from the gen_ai usage attributes', () => {
const span = {
...createMockSpan(),
attributes: {
'gen_ai.usage.input_tokens': 1000,
'gen_ai.usage.output_tokens': 9,
'signoz.gen_ai.usage.tokens.cost': 0.000009,
},
};
render(<SpanSummary selectedSpan={span} />);
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
});
it('omits tokens and cost for non-AI spans', () => {
render(<SpanSummary selectedSpan={createMockSpan()} />);
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,6 @@
.aiBadge {
display: inline-flex;
--badge-primary-outlined-label: var(--ai-badge-color);
--badge-primary-outlined-background: var(--ai-badge-color);
--badge-primary-outlined-border: var(--ai-badge-color);
}

View File

@@ -0,0 +1,44 @@
import { CSSProperties } from 'react';
import { Badge } from '@signozhq/ui/badge';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAiSpanKind } from '../utils/aiTraceUtils';
import { AI_SPAN_BADGES } from './constants';
import styles from './SpanHintBadge.module.scss';
interface SpanHintBadgeProps {
span: SpanV3;
}
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
const aiSpanKind = getAiSpanKind(span);
if (aiSpanKind) {
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
return (
<span
className={styles.aiBadge}
// The outline variant derives text, fill and border from this color.
style={{ '--ai-badge-color': color } as CSSProperties}
>
<Badge
variant="outlined"
color="primary"
testId={`span-hint-badge-${aiSpanKind}`}
>
{label}
</Badge>
</span>
);
}
if (!span.response_status_code) {
return null;
}
return <HttpStatusBadge statusCode={span.response_status_code} />;
}
export default SpanHintBadge;

View File

@@ -0,0 +1,10 @@
import { AiSpanKind } from '../utils/aiTraceUtils';
export const AI_SPAN_BADGES: Record<
AiSpanKind,
{ label: string; color: string }
> = {
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
};

View File

@@ -8,16 +8,12 @@
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l2-foreground);
margin-bottom: 6px;
}
.name {

View File

@@ -9,28 +9,17 @@
}
.content {
font-size: 12px;
color: var(--l1-foreground);
composes: tooltipBody from '../shared.module.scss';
min-width: 223px;
max-width: 360px;
}
.name {
font-weight: 600;
margin-bottom: 4px;
.title {
composes: tooltipTitle from '../shared.module.scss';
}
.row {
line-height: 1.5;
color: var(--l2-foreground);
}
.preview {
// container for additional preview rows
}
.previewKey {
color: var(--l2-foreground);
}
.previewValue {
color: var(--l1-foreground);
.header {
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-2);
}

View File

@@ -1,4 +1,5 @@
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
@@ -7,6 +8,11 @@ import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
import SpanUsageBreakdown from './SpanUsageBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
import styles from './SpanHoverCard.module.scss';
/**
@@ -33,6 +39,7 @@ export interface SpanTooltipContentProps {
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
ai?: SpanAiDetails;
}
export function SpanTooltipContent({
@@ -42,33 +49,54 @@ export function SpanTooltipContent({
relativeStartMs,
durationMs,
previewRows,
ai,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className={styles.content}>
<div className={styles.name} style={{ color }}>
{spanName}
</div>
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
<div className={styles.row}>
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
<div className={styles.header}>
<Typography.Text
className={styles.title}
size="small"
weight="normal"
style={{ color }}
>
{spanName}
</Typography.Text>
{ai?.usage && (
<Typography.Text size="small" weight="normal">
Usage Breakdown
</Typography.Text>
)}
</div>
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
<TraceTooltipSection>
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
<TraceTooltipRow label="status" value={hasError ? 'error' : 'ok'} />
<TraceTooltipRow
label="start"
value={`${toFixed(relativeStartMs, 2)} ms`}
/>
<TraceTooltipRow
label="duration"
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
/>
</TraceTooltipSection>
{previewRows && previewRows.length > 0 && (
<div className={styles.preview}>
<TraceTooltipSection>
{previewRows.map((row) => (
<div
<TraceTooltipRow
key={row.key}
className={styles.row}
data-testid={`span-hover-card-preview-${row.key}`}
>
<span className={styles.previewKey}>{row.key}:</span>{' '}
<span className={styles.previewValue}>{row.value}</span>
</div>
label={row.key}
value={row.value}
testId={`span-hover-card-preview-${row.key}`}
/>
))}
</div>
</TraceTooltipSection>
)}
</div>
);
@@ -130,6 +158,7 @@ export function SpanHoverCard({
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
ai: getAIUsageDetails(span),
},
};
}, [

View File

@@ -0,0 +1,38 @@
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
interface SpanUsageBreakdownProps {
usage: SpanAiUsage;
}
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
const {
inputTokens,
outputTokens,
cacheReadTokens,
cacheCreationTokens,
reasoningTokens,
cost,
} = usage;
return (
<>
<TokenBreakdown
input={inputTokens}
output={outputTokens}
cacheRead={cacheReadTokens}
cacheWrite={cacheCreationTokens}
reasoning={reasoningTokens}
/>
{hasValue(cost) && (
<TraceTooltipSection>
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
</TraceTooltipSection>
)}
</>
);
}
export default SpanUsageBreakdown;

View File

@@ -0,0 +1,81 @@
import {
AiTokenCounts,
formatTokens,
getTokenLabel,
getTotalInputTokens,
} from '../utils/aiTraceUtils';
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
type TokenBreakdownProps = AiTokenCounts;
function TokenBreakdown({
input,
output,
cacheRead,
cacheWrite,
reasoning,
}: TokenBreakdownProps): JSX.Element {
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
return (
<>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Total Input', totalInput)}
value={formatTokens(totalInput)}
isHeading
/>
<TraceTooltipRow
label={getTokenLabel('Cache Read', cacheRead)}
value={formatTokens(cacheRead)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Cache Write', cacheWrite)}
value={formatTokens(cacheWrite)}
isNested
/>
<TraceTooltipRow
label={getTokenLabel('Input', input)}
value={formatTokens(input)}
isNested
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Reasoning', reasoning)}
value={formatTokens(reasoning)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label={getTokenLabel('Output', output)}
value={formatTokens(output)}
isHeading
/>
</TraceTooltipSection>
<TraceTooltipSection>
<TraceTooltipRow
label="Total Token Usage"
value={formatTokens(totalInput + (output ?? 0))}
isHeading
/>
</TraceTooltipSection>
</>
);
}
TokenBreakdown.defaultProps = {
input: undefined,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
reasoning: undefined,
};
export default TokenBreakdown;

View File

@@ -186,6 +186,8 @@ function TraceDetailsHeader({
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceSummary.rootSpanStatusCode}
tokens={traceSummary.ai?.tokens}
cost={traceSummary.ai?.totalCost}
/>
) : (
<DetailsLoader />

View File

@@ -1,6 +1,7 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
@@ -176,9 +177,19 @@ describe('TraceDetailsHeader – trace metadata row', () => {
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
ai: {
tokens: {
input: 12040,
output: 3110,
cacheRead: 0,
cacheWrite: 0,
reasoning: 0,
},
totalCost: 0.0421,
},
};
const mockSummary = (data?: typeof traceMetadata): void => {
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
@@ -201,6 +212,77 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(duration)).toBeInTheDocument();
});
it('renders AI tokens and cost when the summary has them', () => {
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('omits AI tokens and cost when the summary has no ai field', () => {
mockSummary({ ...traceMetadata, ai: undefined });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('shows AI tokens when input and output are both zero', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
},
} as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
});
it('shows zero for a missing input', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: { ...traceMetadata.ai.tokens, input: undefined },
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
});
it('shows zero AI tokens when input and output are both missing', () => {
mockSummary({
...traceMetadata,
ai: {
...traceMetadata.ai,
tokens: {
...traceMetadata.ai.tokens,
input: undefined,
output: undefined,
},
},
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
});
it('omits the cost when the summary sends null', () => {
mockSummary({
...traceMetadata,
ai: { ...traceMetadata.ai, totalCost: null },
} as unknown as SpantypesGettableTraceSummaryDTO);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
});
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
mockSummary(traceMetadata);

View File

@@ -30,13 +30,9 @@
// lives here. Shares visual treatment with the waterfall hover popover
// but is positioned `fixed` instead of anchored.
.tooltip {
composes: tooltipSurface from '../shared.module.scss';
position: fixed;
z-index: 1000;
pointer-events: none;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
}

View File

@@ -8,6 +8,7 @@ import { EventTooltipContent } from '../SpanHoverCard/EventTooltipContent';
import { SpanTooltipContent } from '../SpanHoverCard/SpanHoverCard';
import { DEFAULT_ROW_HEIGHT } from './constants';
import { useCanvasSetup } from './hooks/useCanvasSetup';
import { useCursorTooltipPosition } from './hooks/useCursorTooltipPosition';
import { useFlamegraphCrosshair } from './hooks/useFlamegraphCrosshair';
import { useFlamegraphDrag } from './hooks/useFlamegraphDrag';
import { useFlamegraphDraw } from './hooks/useFlamegraphDraw';
@@ -200,15 +201,11 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
handleHoverMouseLeave,
]);
const tooltipRef = useCursorTooltipPosition<HTMLDivElement>(tooltipContent);
const tooltipElement = tooltipContent
? createPortal(
<div
className={styles.tooltip}
style={{
left: Math.min(tooltipContent.clientX + 15, window.innerWidth - 220),
top: Math.min(tooltipContent.clientY + 15, window.innerHeight - 100),
}}
>
<div ref={tooltipRef} className={styles.tooltip}>
{tooltipContent.event ? (
<EventTooltipContent
eventName={tooltipContent.event.name}

View File

@@ -0,0 +1,36 @@
import { RefObject, useLayoutEffect, useRef } from 'react';
const CURSOR_OFFSET = 15;
interface CursorAnchor {
clientX: number;
clientY: number;
}
function resolveAxis(cursor: number, size: number, viewport: number): number {
const after = cursor + CURSOR_OFFSET;
if (after + size <= viewport) {
return after;
}
return Math.max(0, cursor - CURSOR_OFFSET - size);
}
/** Places a fixed element beside the cursor, flipping sides when it would overflow the viewport. */
export function useCursorTooltipPosition<T extends HTMLElement>(
anchor: CursorAnchor | null,
): RefObject<T> {
const ref = useRef<T>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el || !anchor) {
return;
}
const left = resolveAxis(anchor.clientX, el.offsetWidth, window.innerWidth);
const top = resolveAxis(anchor.clientY, el.offsetHeight, window.innerHeight);
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}, [anchor]);
return ref;
}

View File

@@ -0,0 +1,47 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-2) 0;
border-top: 1px solid;
border-image: linear-gradient(
90deg,
var(--l3-background-transparent),
var(--l3-border),
var(--l3-background-transparent)
)
1;
}
.section:first-child {
padding-top: 0;
border-top: none;
}
.section:last-child {
padding-bottom: 0;
}
.row {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
column-gap: var(--spacing-2);
min-width: 0;
}
.nestedRow {
padding-left: var(--spacing-6);
}
.rowLabel {
min-width: 0;
overflow-wrap: anywhere;
}
.rowValue {
min-width: 0;
overflow-wrap: anywhere;
text-align: right;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,42 @@
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './TraceTooltip.module.scss';
interface TraceTooltipRowProps {
label: string;
value?: string;
isNested?: boolean;
isHeading?: boolean;
testId?: string;
}
function TraceTooltipRow({
label,
value,
isNested,
isHeading,
testId,
}: TraceTooltipRowProps): JSX.Element {
return (
<div
className={cx(styles.row, isNested && styles.nestedRow)}
data-testid={testId}
>
<Typography.Text
size="small"
color={isHeading ? undefined : 'muted'}
className={styles.rowLabel}
>
{label}
</Typography.Text>
{value !== undefined && (
<Typography.Text size="small" className={styles.rowValue}>
{value}
</Typography.Text>
)}
</div>
);
}
export default TraceTooltipRow;

View File

@@ -0,0 +1,15 @@
import { ReactNode } from 'react';
import styles from './TraceTooltip.module.scss';
interface TraceTooltipSectionProps {
children: ReactNode;
}
function TraceTooltipSection({
children,
}: TraceTooltipSectionProps): JSX.Element {
return <div className={styles.section}>{children}</div>;
}
export default TraceTooltipSection;

View File

@@ -203,8 +203,9 @@
.statusCell {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
cursor: pointer;
padding-left: var(--spacing-2);
--badge-border-radius: 2px;
--badge-padding: 3px 6px;
@@ -394,6 +395,11 @@
}
}
.aiSpanIcon {
flex-shrink: 0;
margin: 0 var(--spacing-2);
}
.treeLabel {
color: var(--l1-foreground);
font-family: 'Inter';

View File

@@ -20,7 +20,6 @@ import {
} from '@tanstack/react-table';
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
@@ -28,7 +27,13 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import {
ChevronDown,
ChevronRight,
Link,
ListPlus,
Sparkle,
} from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -39,6 +44,8 @@ import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
import { isAiSpan } from '../../../utils/aiTraceUtils';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../types';
@@ -318,14 +325,23 @@ const SpanOverview = memo(function SpanOverview({
</span>
{/* Colored service dot */}
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
{isAiSpan(span) ? (
<Sparkle
size={10}
color={effectiveColor}
fill={effectiveColor}
className={styles.aiSpanIcon}
/>
) : (
<span
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
style={
{
'--service-dot-color': effectiveColor,
} as React.CSSProperties
}
/>
)}
{/* Span name + service name */}
<span className={styles.treeLabel}>
@@ -944,9 +960,7 @@ function Success(props: ISuccessProps): JSX.Element {
onMouseLeave={(): void => applyHoverClass(null)}
onClick={(): void => handleSpanClick(span)}
>
{span.response_status_code && (
<HttpStatusBadge statusCode={span.response_status_code} />
)}
<SpanHintBadge span={span} />
</div>
);
})}

View File

@@ -0,0 +1,35 @@
.tooltipBody {
display: flex;
flex-direction: column;
letter-spacing: var(--paragraph-small-400-letter-spacing);
color: var(--l1-foreground-hover);
}
.tooltipTitle {
font-family: 'SF Mono', 'Geist Mono', monospace;
word-break: break-all;
}
.tooltipSurface {
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: var(--spacing-4);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
.tooltipContent {
--tooltip-background: color-mix(
in srgb,
var(--l3-background) 60%,
transparent
);
--tooltip-border-color: var(--l2-border);
--tooltip-border-radius: var(--radius-2);
--tooltip-padding: var(--spacing-4);
--tooltip-box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
/* Above the docked span details FloatingPanel (z-index 999). */
--tooltip-z-index: 1000;
backdrop-filter: blur(20px);
}

View File

@@ -46,6 +46,8 @@ import {
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
TRACE_SCENARIOS,
type TraceScenario,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -103,6 +105,13 @@ const spanLogScopeOf = (body: QueryRangeRequestV5): SpanLogScope => {
export const traceDetailsMocks = defineStoryMocks({
controls: {
scenario: choiceControl<TraceScenario>('Scenario', {
group: TRACE,
description:
'The spans the trace is built from: a checkout, one span per HTTP status badge, or an agent run with LLM, tool and agent spans and the AI totals in the header.',
options: TRACE_SCENARIOS,
value: 'checkout',
}),
spans: countControl('Spans', {
group: TRACE,
description:
@@ -151,6 +160,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
handlers: (values, response) => {
const trace = {
scenario: values.scenario,
spans: values.spans,
errors: values.errors,
traceStart: traceStartOf(),
@@ -288,7 +298,7 @@ export const traceDetailsMocks = defineStoryMocks({
},
config: (values) => ({
route: values.spanPanel
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
: `/trace/${STORY_TRACE_ID}`,
}),
effect: (values) => {

View File

@@ -13,7 +13,12 @@ import {
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
import {
focusSpanIndex,
scenarioSpanCount,
spanIdAt,
STORY_TRACE_ID,
} from './__story_mockdata__/traceDetails';
import {
openRowMenu,
startDownload,
@@ -96,6 +101,40 @@ export const BottomStripLoading: Story = {
args: { bottomStrip: true, dataState: 'loading' },
};
/**
* One span per HTTP status badge the waterfall draws: informational, success,
* redirect, client and server errors, and a span with no status code and so no
* badge.
*/
export const StatusCodes: Story = {
args: {
scenario: 'status-codes',
spans: scenarioSpanCount('status-codes'),
spanPanel: false,
},
};
const AI_SPANS = scenarioSpanCount('ai');
/**
* An agent run: LLM, tool and agent badges with sparkle icons in the waterfall,
* token and cost totals in the header, the span panel on an LLM call with its
* tokens and cost, and that span's hover card open on its usage breakdown.
*/
export const AiTrace: Story = {
args: { scenario: 'ai', spans: AI_SPANS },
play: async ({ canvasElement }): Promise<void> => {
const row = await within(canvasElement).findByTestId(
`cell-0-${spanIdAt(focusSpanIndex('ai', AI_SPANS))}`,
undefined,
untilLoaded,
);
await fireEvent.mouseOver(row);
await screen.findByText('Usage Breakdown', undefined, untilLoaded);
},
};
/**
* Turns on Highlight errors, which writes `has_error = true` into the filter and
* expands the bar to show it.

View File

@@ -23,6 +23,10 @@ interface SpanTemplate {
name: string;
kind: string;
failing?: boolean;
/** The span the panel opens on when nothing fails. */
focus?: boolean;
/** `response_status_code`; failing spans default to 503, the rest to 200. */
statusCode?: string;
attributes: Record<string, string | number | boolean>;
}
@@ -123,6 +127,145 @@ const SPAN_TEMPLATES: SpanTemplate[] = [
},
];
/** One span per badge colour, plus a span with no status code and so no badge. */
const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
['200', 'GET /status/ok'],
['101', 'GET /ws/upgrade'],
['201', 'POST /status/created'],
['204', 'DELETE /status/no-content'],
['301', 'GET /status/moved'],
['304', 'GET /status/not-modified'],
['400', 'POST /status/bad-request'],
['401', 'GET /status/unauthorized'],
['404', 'GET /status/not-found'],
['429', 'GET /status/rate-limited'],
['500', 'GET /status/internal-error'],
['502', 'GET /status/bad-gateway'],
['503', 'GET /status/unavailable'],
['504', 'GET /status/timeout'],
['', 'internal.compute'],
].map(
([statusCode, name]): SpanTemplate => ({
service: 'status-api',
name,
kind: statusCode ? 'Server' : 'Internal',
failing: Number(statusCode) >= 500,
statusCode,
attributes: statusCode
? {
'http.method': name.split(' ')[0],
'http.status_code': Number(statusCode),
}
: {},
}),
);
/**
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans
* cover cache counted inside input (OpenAI), cache counted beside it
* (Anthropic), reasoning, and an embedding with input only.
*/
const AI_TEMPLATES: SpanTemplate[] = [
{
service: 'support-agent',
name: 'invoke_agent support-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'support-agent' },
},
{
service: 'support-agent',
name: 'chat gpt-4o',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'gpt-4o-2024-08-06',
'gen_ai.usage.input_tokens': 1840,
'gen_ai.usage.output_tokens': 212,
'gen_ai.usage.cache_read.input_tokens': 1024,
'gen_ai.usage.cache_creation.input_tokens': 0,
'signoz.gen_ai.usage.tokens.cost': 0.00566,
},
},
{
service: 'support-agent',
name: 'execute_tool search_docs',
kind: 'Internal',
attributes: { 'gen_ai.tool.name': 'search_docs' },
},
{
service: 'support-agent',
name: 'chat claude-sonnet-4',
kind: 'Client',
focus: true,
attributes: {
'gen_ai.request.model': 'claude-sonnet-4-20250514',
'gen_ai.usage.input_tokens': 86,
'gen_ai.usage.output_tokens': 640,
'gen_ai.usage.cache_read.input_tokens': 4200,
'gen_ai.usage.cache_creation.input_tokens': 1300,
'signoz.gen_ai.usage.tokens.cost': 0.01853,
},
},
{
service: 'docs-api',
name: 'GET /docs/search',
kind: 'Server',
attributes: {
'http.method': 'GET',
'http.route': '/docs/search',
'http.status_code': 200,
},
},
{
service: 'support-agent',
name: 'execute_tool fetch_ticket',
kind: 'Internal',
failing: true,
attributes: { 'gen_ai.tool.name': 'fetch_ticket' },
},
{
service: 'support-agent',
name: 'invoke_agent triage-agent',
kind: 'Internal',
attributes: { 'gen_ai.agent.name': 'triage-agent' },
},
{
service: 'support-agent',
name: 'embeddings text-embedding-3-small',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'text-embedding-3-small',
'gen_ai.usage.input_tokens': 312,
'signoz.gen_ai.usage.tokens.cost': 0.00000624,
},
},
{
service: 'support-agent',
name: 'chat o3-mini',
kind: 'Client',
attributes: {
'gen_ai.request.model': 'o3-mini-2025-01-31',
'gen_ai.usage.input_tokens': 920,
'gen_ai.usage.output_tokens': 1480,
'gen_ai.usage.reasoning.output_tokens': 1152,
'signoz.gen_ai.usage.tokens.cost': 0.007524,
},
},
];
export const TRACE_SCENARIOS = ['checkout', 'status-codes', 'ai'] as const;
export type TraceScenario = (typeof TRACE_SCENARIOS)[number];
const SCENARIO_TEMPLATES: Record<TraceScenario, SpanTemplate[]> = {
checkout: SPAN_TEMPLATES,
'status-codes': STATUS_CODE_TEMPLATES,
ai: AI_TEMPLATES,
};
/** Spans it takes for the scenario to show every template once. */
export const scenarioSpanCount = (scenario: TraceScenario): number =>
SCENARIO_TEMPLATES[scenario].length;
const TRACE_SERVICES = [
...new Set(SPAN_TEMPLATES.map(({ service }) => service)),
];
@@ -146,6 +289,7 @@ interface BuiltSpan {
}
interface TraceOptions {
scenario: TraceScenario;
spans: number;
errors: boolean;
/** Epoch milliseconds the root span starts at. */
@@ -156,24 +300,33 @@ export const spanIdAt = (index: number): string =>
`b17e${index.toString(16).padStart(12, '0')}`;
/**
* The span the story opens the panel on: the first failing one, so its events
* and error styling are on screen, and the root when nothing fails.
* The span the story opens the panel on: the scenario's focus span, else the
* first failing one, so its events and error styling are on screen, and the
* root when neither is in the trace.
*/
export const focusSpanIndex = (spans: number): number => {
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
export const focusSpanIndex = (
scenario: TraceScenario,
spans: number,
): number => {
const templates = SCENARIO_TEMPLATES[scenario];
const focus = templates.findIndex((template) => template.focus);
const failing = templates.findIndex((template) => template.failing);
const index = focus >= 0 ? focus : failing;
return failing >= 0 && failing < spans ? failing : 0;
return index >= 0 && index < spans ? index : 0;
};
const buildSpans = ({
scenario,
spans,
errors,
traceStart,
}: TraceOptions): BuiltSpan[] => {
const built: BuiltSpan[] = [];
const templates = SCENARIO_TEMPLATES[scenario];
for (let index = 0; index < spans; index += 1) {
const template = SPAN_TEMPLATES[index % SPAN_TEMPLATES.length];
const template = templates[index % templates.length];
const parent = index === 0 ? undefined : built[parentIndexOf(index)];
// Children fit inside their parent's window, second child after the first,
// so no span outlives its parent and no two siblings overlap.
@@ -257,7 +410,8 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
status_code: span.hasError ? 2 : 1,
status_code_string: span.hasError ? 'Error' : 'Ok',
status_message: span.hasError ? 'upstream unavailable' : '',
response_status_code: span.hasError ? '503' : '200',
response_status_code:
span.template.statusCode ?? (span.hasError ? '503' : '200'),
has_children: span.hasChildren,
sub_tree_node_count: span.subTreeNodeCount,
level: span.level,
@@ -304,6 +458,36 @@ export const traceWaterfallResponse = (
};
};
const numberAttribute = (span: BuiltSpan, key: string): number =>
Number(span.template.attributes[key] ?? 0);
/** The trace's AI totals, summed over its spans the way the backend does. */
const traceAiSummary = (
spans: BuiltSpan[],
): GetTraceSummary200['data']['ai'] => {
const aiSpans = spans.filter(({ template }) =>
Object.keys(template.attributes).some((key) => key.includes('gen_ai.')),
);
if (aiSpans.length === 0) {
return undefined;
}
const sum = (key: string): number =>
aiSpans.reduce((total, span) => total + numberAttribute(span, key), 0);
return {
tokens: {
input: sum('gen_ai.usage.input_tokens'),
output: sum('gen_ai.usage.output_tokens'),
cacheRead: sum('gen_ai.usage.cache_read.input_tokens'),
cacheWrite: sum('gen_ai.usage.cache_creation.input_tokens'),
reasoning: sum('gen_ai.usage.reasoning.output_tokens'),
},
totalCost: sum('signoz.gen_ai.usage.tokens.cost'),
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
@@ -321,6 +505,7 @@ export const traceSummaryResponse = (
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
ai: traceAiSummary(spans),
},
};
};
@@ -472,7 +657,7 @@ export const spanLogRows = (
): RawRow[] => {
const step = ROOT_DURATION_MS / Math.max(count, 1);
const spans = buildSpans(options);
const focus = spans[focusSpanIndex(options.spans)];
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
return Array.from({ length: count }, (_, index) => {
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;

View File

@@ -0,0 +1,51 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import {
AiSpanKind,
getAiSpanKind,
getTotalInputTokens,
isAiSpan,
} from '../aiTraceUtils';
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
({ attributes, resource: {} }) as unknown as SpanV3;
describe('getAiSpanKind', () => {
it.each<[Record<string, string | number>, AiSpanKind]>([
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
[
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
AiSpanKind.Tool,
],
[
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
AiSpanKind.Agent,
],
])('classifies %p as %s', (attributes, kind) => {
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
});
it('returns undefined for non-AI spans and empty values', () => {
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
expect(isAiSpan(spanWith({}))).toBe(false);
});
});
describe('getTotalInputTokens', () => {
it.each([
// Cache reported inside input (OpenAI): input already includes it.
[{ input: 1000, cacheRead: 600, cacheWrite: 100 }, 1000],
// Cache reported separately (Anthropic): add it.
[{ input: 50, cacheRead: 600, cacheWrite: 100 }, 750],
[{ input: 71, cacheRead: 0, cacheWrite: 0 }, 71],
[{ input: 10 }, 10],
[{ cacheRead: 600 }, 600],
])('%p → %p', (tokens, expected) => {
expect(getTotalInputTokens(tokens)).toBe(expected);
});
});

View File

@@ -0,0 +1,176 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
export const GEN_AI_KEYS = {
requestModel: 'gen_ai.request.model',
toolName: 'gen_ai.tool.name',
agentName: 'gen_ai.agent.name',
inputTokens: 'gen_ai.usage.input_tokens',
outputTokens: 'gen_ai.usage.output_tokens',
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
cost: 'signoz.gen_ai.usage.tokens.cost',
} as const;
export enum AiSpanKind {
Llm = 'llm',
Tool = 'tool',
Agent = 'agent',
}
const LLM_KEYS = [
GEN_AI_KEYS.requestModel,
GEN_AI_KEYS.inputTokens,
GEN_AI_KEYS.outputTokens,
GEN_AI_KEYS.cacheReadTokens,
GEN_AI_KEYS.cacheCreationTokens,
GEN_AI_KEYS.reasoningTokens,
];
/** Keeps numeric 0, which `getSpanAttribute`'s `||` chain drops. */
export function getGenAiValue(
span: SpanV3,
key: string,
): string | number | boolean | undefined {
const value = span.resource?.[key] ?? span.attributes?.[key];
return value === '' ? undefined : value;
}
function hasAttribute(span: SpanV3, key: string): boolean {
return getGenAiValue(span, key) !== undefined;
}
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
return AiSpanKind.Tool;
}
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
return AiSpanKind.Agent;
}
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
return AiSpanKind.Llm;
}
return undefined;
}
export function isAiSpan(span: SpanV3): boolean {
return getAiSpanKind(span) !== undefined;
}
/** Token counts; `null` or missing means the backend did not send the value. */
export interface AiTokenCounts {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
reasoning?: number;
}
export function hasValue(value: number | null | undefined): value is number {
return value !== undefined && value !== null;
}
/**
* Input including cache read/write, counted once. Providers that report cache
* inside `input` (OpenAI) have `input >= cache`; those that report it
* separately (Anthropic) usually have `input < cache`.
*/
export function getTotalInputTokens({
input,
cacheRead,
cacheWrite,
}: AiTokenCounts): number {
const inputValue = input ?? 0;
const cache = (cacheRead ?? 0) + (cacheWrite ?? 0);
return inputValue < cache ? inputValue + cache : inputValue;
}
/** `Input Token` for a count of 1, `Input Tokens` otherwise. */
export function getTokenLabel(
prefix: string,
count: number | null | undefined,
): string {
return `${prefix} ${count === 1 ? 'Token' : 'Tokens'}`;
}
/** Missing values render as `0`. */
export function formatTokens(value: number | null | undefined): string {
return hasValue(value) ? value.toLocaleString('en-US') : '0';
}
export function formatCost(value: number): string {
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
}
export interface SpanAiUsage {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheCreationTokens?: number;
reasoningTokens?: number;
cost?: number;
}
export interface SpanAiDetails {
model?: string;
toolName?: string;
agentName?: string;
usage?: SpanAiUsage;
}
function getGenAiAttributeAsString(
span: SpanV3,
key: string,
): string | undefined {
const value = getGenAiValue(span, key);
return value === undefined ? undefined : String(value);
}
function getGenAiAttributeAsNumber(
span: SpanV3,
key: string,
): number | undefined {
const value = getGenAiValue(span, key);
if (value === undefined) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
export function getAIUsageDetails(span: SpanV3): SpanAiDetails | undefined {
const usage: SpanAiUsage = {
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
cacheCreationTokens: getGenAiAttributeAsNumber(
span,
GEN_AI_KEYS.cacheCreationTokens,
),
reasoningTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
};
const hasUsage = Object.values(usage).some((value) => value !== undefined);
const details: SpanAiDetails = {
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
usage: hasUsage ? usage : undefined,
};
return details.model || details.toolName || details.agentName || hasUsage
? details
: undefined;
}
export function convertToAiTokens(usage: SpanAiUsage): AiTokenCounts {
return {
input: usage.inputTokens,
output: usage.outputTokens,
cacheRead: usage.cacheReadTokens,
cacheWrite: usage.cacheCreationTokens,
reasoning: usage.reasoningTokens,
};
}

View File

@@ -4,13 +4,12 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/gorilla/mux"
)
func (provider *provider) addStatsReporterRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/stats", handler.New(
provider.authzMiddleware.ViewAccess(provider.statsHandler.Get),
provider.authzMiddleware.OpenAccess(provider.statsHandler.Get),
handler.OpenAPIDef{
ID: "GetStats",
Tags: []string{"stats"},
@@ -23,7 +22,7 @@ func (provider *provider) addStatsReporterRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
SecuritySchemes: newScopedSecuritySchemes(nil),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err

View File

@@ -17,8 +17,10 @@ const (
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
GenAISystemInstructions = "gen_ai.system_instructions"
GenAIResponseFinishReasons = "gen_ai.response.finish_reasons"
GenAIToolCallID = "gen_ai.tool.call.id"
GenAIToolCallArguments = "gen_ai.tool.call.arguments"

View File

@@ -1,5 +1,6 @@
// The message types mirror the OpenTelemetry GenAI message schemas, one type per schema definition:
// open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/gen-ai-{input,output}-messages.json.
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-input-messages.json
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-output-messages.json
// Keys the schema does not name are dropped; the raw attribute on the span still has them.
package aiobservabilitytypes

View File

@@ -4,25 +4,45 @@ import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
FormatterSemconv = "semconv"
FormatterOpenAIChat = "openai.chat"
FormatterText = "text"
FormatterGeneric = "generic"
FormatterSemconv = "semconv"
FormatterOpenInference = "openinference"
FormatterLangChain = "langchain"
FormatterOpenRouter = "openrouter"
FormatterAnthropic = "anthropic"
FormatterGemini = "gemini"
FormatterOpenAIChat = "openai.chat"
FormatterOpenAIResponses = "openai.responses"
FormatterText = "text"
FormatterGeneric = "generic"
)
var dataURL = regexp.MustCompile(`^data:([^;,]+);base64,(.+)$`)
// Provider spellings of the finish reasons; the enum values themselves pass through.
// Provider spellings of the finish reasons; the enum values themselves pass through. tool_calls and
// function_call are OpenAI's, completed, failed, cancelled, incomplete and max_output_tokens the Responses
// status and incomplete reason, end_turn, stop_sequence, max_tokens, tool_use, refusal and
// model_context_window_exceeded Anthropic's, the upper-case ones Gemini's, tool-calls and content-filter
// the Vercel AI SDK's:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L44782
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/stop_reason.py#L5
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L510
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-finish-reason.ts
var finishReasonAliases = map[string]FinishReason{
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall,
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop,
"max_tokens": FinishReasonLength,
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall, "tool-calls": FinishReasonToolCall, "tool_use": FinishReasonToolCall,
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop, "completed": FinishReasonStop,
"max_tokens": FinishReasonLength, "max_output_tokens": FinishReasonLength, "model_context_window_exceeded": FinishReasonLength,
"content-filter": FinishReasonContentFilter, "refusal": FinishReasonContentFilter, "safety": FinishReasonContentFilter, "recitation": FinishReasonContentFilter,
"blocklist": FinishReasonContentFilter, "prohibited_content": FinishReasonContentFilter, "spii": FinishReasonContentFilter, "image_safety": FinishReasonContentFilter,
"failed": FinishReasonError, "cancelled": FinishReasonError, "incomplete": FinishReasonError, "malformed_function_call": FinishReasonError,
}
// FormattedMessages is the OTel GenAI view of one span's messages; Warnings holds what the converter could
@@ -35,19 +55,42 @@ type FormattedMessages struct {
// FormatMessages builds the OTel GenAI view of a span's messages from the formats SDKs write. attributes
// is the span's JSON attribute document, nested by key segment, so an object value is whole. Each
// message value is a JSON string or a structured value. A tool call and its result take precedence over
// messages beside them.
// message value is a JSON string or a structured value. An embeddings call has no messages, whatever the
// mapper moved onto the message attributes. OpenInference's flattened messages come first,
// then a tool call and its result, then the AI SDK's ai.* keys, then the message attributes, then
// openllmetry's flattened prompts and completions. gen_ai.system_instructions leads the
// input and gen_ai.response.finish_reasons overrides the finish reasons the converter found, whichever
// converter ran.
func FormatMessages(attributes map[string]any) FormattedMessages {
f := &formatting{attributes: attributes, out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
if isEmbeddings(attributes) {
return f.out
}
switch {
case f.openInference():
case attribute(f.attributes, GenAIToolCallArguments) != nil || attribute(f.attributes, GenAIToolCallResult) != nil:
f.tool()
case f.vercel():
case attribute(f.attributes, GenAIInputMessages) != nil || attribute(f.attributes, GenAIOutputMessages) != nil:
f.messages()
case f.traceloop():
}
f.prependSystemInstructions()
f.fillFinishReasons()
return f.out
}
// isEmbeddings matches the operation name as the semconv embeddings, openllmetry's llm.request.type
// embedding and OpenInference's span kind EMBEDDING, copied by the mapper, or the AI SDK's ai.embed* ids.
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/registry.yaml#L658
func isEmbeddings(attributes map[string]any) bool {
switch strings.ToLower(stringOf(attribute(attributes, GenAIOperationName))) {
case "embeddings", "embedding":
return true
}
return strings.HasPrefix(stringOf(attribute(attributes, vercelOperation)), "ai.embed")
}
type formatting struct {
attributes map[string]any
out FormattedMessages
@@ -63,32 +106,104 @@ func (f *formatting) messages() {
if input := attribute(f.attributes, GenAIInputMessages); input != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(input), RoleUser)
for _, m := range msgs {
f.out.Input = append(f.out.Input, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
f.out.Input = inputMessages(msgs)
}
if output := attribute(f.attributes, GenAIOutputMessages); output != nil {
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
f.completionToolCalls()
}
if inputLabel != "" && outputLabel != "" && outputLabel != inputLabel {
f.warnMixedFormats(inputLabel, outputLabel)
}
// OpenRouter's answer object or an Anthropic response beside the OpenAI chat request it answers is one
// format, not a mix: an Anthropic request without system and without Anthropic-only blocks reads as an
// OpenAI chat request.
func (f *formatting) warnMixedFormats(inputLabel, outputLabel string) {
switch {
case inputLabel == "" || outputLabel == "" || outputLabel == inputLabel:
case inputLabel == FormatterOpenAIChat && (outputLabel == FormatterOpenRouter || outputLabel == FormatterAnthropic):
default:
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
}
}
// prependSystemInstructions adds gen_ai.system_instructions, a list of parts, as a leading system message,
// unless the input already starts with a system message of the same text: the OpenInference strands
// processor keeps the attribute and inserts it as message 0 as well. A span without messages, such as
// create_agent, stays without them.
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/gen-ai-system-instructions.json
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
func (f *formatting) prependSystemInstructions() {
raw := attribute(f.attributes, GenAISystemInstructions)
if raw == nil || (len(f.out.Input) == 0 && len(f.out.Output) == 0) {
return
}
parts := systemParts(decode(raw))
if len(parts) == 0 || (len(f.out.Input) > 0 && f.out.Input[0].Role == RoleSystem && textOf(f.out.Input[0].Parts) == textOf(parts)) {
return
}
f.out.Input = append(InputMessages{{Role: RoleSystem, Parts: parts}}, f.out.Input...)
}
// fillFinishReasons applies gen_ai.response.finish_reasons, one entry per output message, over
// whatever the converter found in the payload.
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/registry.yaml#L210
func (f *formatting) fillFinishReasons() {
raw := attribute(f.attributes, GenAIResponseFinishReasons)
if raw == nil || len(f.out.Output) == 0 {
return
}
reasons := stringList(decode(raw))
if len(reasons) != len(f.out.Output) {
f.warn("gen_ai.response.finish_reasons has %d entries for %d output messages", len(reasons), len(f.out.Output))
return
}
for i, reason := range reasons {
if fr := finishReason(reason); fr != nil {
f.out.Output[i].FinishReason = fr
}
}
}
// side converts one attribute value by its shape; role is what a bare string on this side is
// spoken as.
func (f *formatting) side(value any, role Role) (OutputMessages, string) {
switch v := value.(type) {
case []any:
if first, ok := firstObject(v); ok {
if _, ok := first["parts"]; ok {
switch {
case isGeminiContent(first):
return f.geminiContents(v), FormatterGemini
case first["parts"] != nil:
return f.semconv(v), FormatterSemconv
}
if _, ok := first["role"]; ok {
case isAnthropicMessages(v):
return f.anthropicMessages(v), FormatterAnthropic
case first["role"] != nil:
return f.openAIChatMessages(v), FormatterOpenAIChat
}
}
case map[string]any:
switch {
case isLangChainRun(v):
return f.langChainRun(v), FormatterLangChain
case isOpenRouterCompletion(v):
return f.openRouterCompletion(v), FormatterOpenRouter
case isResponsesResponse(v):
return f.responsesResponse(v), FormatterOpenAIResponses
case isResponsesRequest(v):
return f.responsesRequest(v), FormatterOpenAIResponses
case isAnthropicResponse(v):
return f.anthropicResponse(v), FormatterAnthropic
case isAnthropicRequest(v):
return f.anthropicRequest(v), FormatterAnthropic
case isGeminiResponse(v):
return f.geminiResponse(v), FormatterGemini
case isGeminiRequest(v):
return f.geminiRequest(v), FormatterGemini
case isGeminiContent(v):
return OutputMessages{geminiContent(v, role)}, FormatterGemini
}
if choices, ok := v["choices"].([]any); ok {
return f.openAIChatResponse(choices), FormatterOpenAIChat
}
@@ -121,6 +236,44 @@ func attribute(doc map[string]any, key string) any {
return current
}
// indexed reads a flattened list, "<key>.<N>.*" keys stored as an object keyed by N, in numeric order.
// A list value is returned as is.
func indexed(doc map[string]any, key string) []map[string]any {
var items []any
switch v := attribute(doc, key).(type) {
case []any:
items = v
case map[string]any:
indexes := make([]int, 0, len(v))
byIndex := make(map[int]any, len(v))
for k, item := range v {
n, err := strconv.Atoi(k)
if err != nil {
continue
}
indexes = append(indexes, n)
byIndex[n] = item
}
sort.Ints(indexes)
for _, n := range indexes {
items = append(items, byIndex[n])
}
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// indexedAt reads one entry of a flattened list by its index.
func indexedAt(doc map[string]any, key string, i int) map[string]any {
m, _ := attribute(doc, key+"."+strconv.Itoa(i)).(map[string]any)
return m
}
func firstObject(list []any) (map[string]any, bool) {
if len(list) == 0 {
return nil, false
@@ -133,6 +286,31 @@ func genericMessage(value any) OutputMessage {
return OutputMessage{Parts: Parts{part(GenericPart{"type": FormatterGeneric, "content": stringOf(value)})}}
}
func inputMessages(msgs OutputMessages) InputMessages {
out := make(InputMessages, 0, len(msgs))
for _, m := range msgs {
out = append(out, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
return out
}
// foldAssistant merges runs of assistant messages into one and drops messages without parts, for
// SDKs that write one message per output item.
func foldAssistant(msgs OutputMessages) OutputMessages {
out := make(OutputMessages, 0, len(msgs))
for _, msg := range msgs {
if len(out) > 0 && msg.Role == RoleAssistant && out[len(out)-1].Role == RoleAssistant {
out[len(out)-1].Parts = append(out[len(out)-1].Parts, msg.Parts...)
continue
}
if len(msg.Parts) == 0 {
continue
}
out = append(out, msg)
}
return out
}
// decode parses a JSON-encoded attribute value, twice when an SDK encoded the string again.
// Anything that is not JSON is returned as is.
func decode(value any) any {
@@ -179,6 +357,23 @@ func finishReason(value string) *FinishReason {
return &fr
}
// stringList reads a list of strings, or a bare string as a list of one.
func stringList(value any) []string {
switch v := value.(type) {
case []any:
out := make([]string, 0, len(v))
for _, item := range v {
out = append(out, stringOf(item))
}
return out
case string:
if v != "" {
return []string{v}
}
}
return nil
}
func optionalString(value any) *string {
s := stringOf(value)
if s == "" {
@@ -195,8 +390,61 @@ func textPart(content string) TextPart {
return TextPart{Type: PartTypeText, Content: content}
}
// contentPart converts one OpenAI content block, which semconv parts may also carry; a block of
// any other type is kept as sent.
// textOf joins the text parts, for comparing a system message with gen_ai.system_instructions.
func textOf(parts Parts) string {
var texts []string
for _, p := range parts {
if t, ok := p.Value.(TextPart); ok {
texts = append(texts, t.Content)
}
}
return strings.Join(texts, "\n")
}
// splitToolResults moves tool_call_response parts out of a message into a tool message before it,
// for APIs that carry tool results inside the next user turn.
func splitToolResults(msg OutputMessage) OutputMessages {
results, rest := Parts{}, Parts{}
for _, p := range msg.Parts {
if _, ok := p.Value.(ToolCallResponsePart); ok {
results = append(results, p)
} else {
rest = append(rest, p)
}
}
if len(results) == 0 {
return OutputMessages{msg}
}
out := OutputMessages{{Role: RoleTool, Parts: results}}
if len(rest) > 0 {
msg.Parts = rest
out = append(out, msg)
}
return out
}
// systemParts reads a system prompt given outside the history, a list of parts or a bare string.
func systemParts(value any) Parts {
switch v := value.(type) {
case []any:
parts := make(Parts, 0, len(v))
for _, item := range v {
parts = append(parts, contentPart(item))
}
return parts
case string:
if v != "" {
return Parts{part(textPart(v))}
}
}
return nil
}
// contentPart converts one content block in the OpenAI Chat Completions or Responses spelling, which
// semconv parts may also carry; a text part in the semconv spelling has content where OpenAI has text.
// A block of any other type is kept as sent.
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53827
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57589
func contentPart(item any) Part {
p, ok := item.(map[string]any)
if !ok {
@@ -206,11 +454,14 @@ func contentPart(item any) Part {
return part(GenericPart{"type": FormatterGeneric, "content": stringOf(item)})
}
switch stringOf(p["type"]) {
case "text":
case "text", "input_text", "output_text", "summary_text":
if p["text"] == nil {
return part(textPart(stringOf(p["content"])))
}
return part(textPart(stringOf(p["text"])))
case "refusal":
return part(textPart(stringOf(p["refusal"])))
case "image_url":
case "image_url", "input_image":
url := stringOf(p["image_url"])
if ref, ok := p["image_url"].(map[string]any); ok {
url = stringOf(ref["url"])
@@ -218,10 +469,20 @@ func contentPart(item any) Part {
if url != "" {
return part(mediaPart(url, ModalityImage))
}
if id := stringOf(p["file_id"]); id != "" {
return part(FilePart{Type: PartTypeFile, Modality: ModalityImage, FileID: id})
}
case "file":
if f, ok := filePart(p); ok {
return f
}
case "input_file":
if f, ok := filePart(map[string]any{"file": p}); ok {
return f
}
if url := stringOf(p["file_url"]); url != "" {
return part(mediaPart(url, ModalityDocument))
}
}
return part(GenericPart(p))
}
@@ -239,6 +500,19 @@ func filePart(block map[string]any) (Part, bool) {
return Part{}, false
}
// modalityOf maps a MIME type to the schema modality, document for anything that is not media.
func modalityOf(mimeType string) Modality {
switch {
case strings.HasPrefix(mimeType, "image/"):
return ModalityImage
case strings.HasPrefix(mimeType, "audio/"):
return ModalityAudio
case strings.HasPrefix(mimeType, "video/"):
return ModalityVideo
}
return ModalityDocument
}
// mediaPart places a data URL in a BlobPart, any other URL in a UriPart.
func mediaPart(url string, modality Modality) any {
if m := dataURL.FindStringSubmatch(url); m != nil {

View File

@@ -0,0 +1,156 @@
package aiobservabilitytypes
import "strings"
// Format: Anthropic Messages, a request {system, messages} or a response {content, stop_reason}:
// https://platform.claude.com/docs/en/api/messages
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message_create_params.py#L181
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message_param.py#L28
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message.py#L91
// Written by: gateways recording raw Anthropic bodies. OpenInference flattens these into
// llm.*_messages, which take precedence.
// Handled: string and block content; text, image and document sources of every type, tool_use,
// tool_result, thinking, redacted_thinking, server_tool_use and the *_tool_result blocks. A tool_result
// inside a user turn becomes a tool message before it.
// Not yet: a request without system and without Anthropic-only blocks is indistinguishable from an
// OpenAI chat request and formats as one.
// Block types only the Messages API has, which tell its message list from an OpenAI chat one:
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/tool_use_block_param.py#L23
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/thinking_block_param.py#L21
var anthropicBlockTypes = map[string]bool{
"tool_use": true, "tool_result": true, "thinking": true, "redacted_thinking": true, "server_tool_use": true, "document": true,
}
func isAnthropicRequest(m map[string]any) bool {
messages, ok := m["messages"].([]any)
if !ok {
return false
}
return m["system"] != nil || isAnthropicMessages(messages)
}
func isAnthropicResponse(m map[string]any) bool {
if _, ok := m["content"].([]any); !ok {
return false
}
_, hasStop := m["stop_reason"]
return hasStop || stringOf(m["type"]) == "message"
}
func isAnthropicMessages(list []any) bool {
for _, item := range list {
m, _ := item.(map[string]any)
blocks, _ := m["content"].([]any)
for _, block := range blocks {
b, _ := block.(map[string]any)
typ := stringOf(b["type"])
if anthropicBlockTypes[typ] || strings.HasSuffix(typ, "_tool_result") || (typ == "image" && b["source"] != nil) {
return true
}
}
}
return false
}
func (f *formatting) anthropicRequest(m map[string]any) OutputMessages {
messages, _ := m["messages"].([]any)
msgs := f.anthropicMessages(messages)
if parts := systemParts(m["system"]); len(parts) > 0 {
return append(OutputMessages{{Role: RoleSystem, Parts: parts}}, msgs...)
}
return msgs
}
func (f *formatting) anthropicMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: anthropicContent(m["content"])}
out = append(out, splitToolResults(msg)...)
}
return out
}
// anthropicResponse is one assistant message with the stop reason.
func (f *formatting) anthropicResponse(m map[string]any) OutputMessages {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: anthropicContent(m["content"]), FinishReason: finishReason(stringOf(m["stop_reason"]))}
if msg.Role.IsZero() {
msg.Role = RoleAssistant
}
return OutputMessages{msg}
}
func anthropicContent(content any) Parts {
parts := Parts{}
switch v := content.(type) {
case string:
if v != "" {
parts = append(parts, part(textPart(v)))
}
case []any:
for _, item := range v {
if b, ok := item.(map[string]any); ok {
parts = append(parts, anthropicBlock(b))
} else {
parts = append(parts, contentPart(item))
}
}
}
return parts
}
// anthropicBlock converts one content block:
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/tool_result_block_param.py#L29
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/redacted_thinking_block_param.py#L15
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/server_tool_use_block_param.py#L33
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/web_search_tool_result_block_param.py#L22
func anthropicBlock(b map[string]any) Part {
typ := stringOf(b["type"])
switch {
case typ == "text":
return part(textPart(stringOf(b["text"])))
case typ == "image" || typ == "document":
return anthropicSource(b, typ)
case typ == "tool_use":
return part(ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(b["id"]), Name: stringOf(b["name"]), Arguments: b["input"]})
case typ == "tool_result":
return part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(b["tool_use_id"]), Response: toolResponse(b["content"])})
case typ == "thinking":
return part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(b["thinking"])})
case typ == "redacted_thinking":
return part(ReasoningPart{Type: PartTypeReasoning})
case typ == "server_tool_use":
return part(ServerToolCallPart{Type: PartTypeServerToolCall, ID: optionalString(b["id"]), Name: stringOf(b["name"]), ServerToolCall: GenericServerToolCall(b)})
case strings.HasSuffix(typ, "_tool_result"):
return part(ServerToolCallResponsePart{Type: PartTypeServerToolCallResponse, ID: optionalString(b["tool_use_id"]), ServerToolCallResponse: GenericServerToolCallResponse(b)})
}
return part(GenericPart(b))
}
// anthropicSource reads the {source: {type: base64|url|file|text, ...}} of an image or document block:
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/image_block_param.py#L14
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/document_block_param.py#L16
func anthropicSource(b map[string]any, typ string) Part {
source, _ := b["source"].(map[string]any)
mime := optionalString(source["media_type"])
modality := ModalityDocument
if typ == "image" {
modality = ModalityImage
}
switch stringOf(source["type"]) {
case "base64":
return part(BlobPart{Type: PartTypeBlob, Modality: modality, MimeType: mime, Content: stringOf(source["data"])})
case "url":
return part(UriPart{Type: PartTypeURI, Modality: modality, MimeType: mime, URI: stringOf(source["url"])})
case "file":
return part(FilePart{Type: PartTypeFile, Modality: modality, MimeType: mime, FileID: stringOf(source["file_id"])})
case "text":
return part(textPart(stringOf(source["data"])))
}
return part(GenericPart(b))
}

View File

@@ -0,0 +1,90 @@
package aiobservabilitytypes
import "testing"
// Cases follow the Anthropic Messages API shapes; no capture in SigNoz/scripts records raw Anthropic
// bodies.
// https://platform.claude.com/docs/en/api/messages
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message_param.py#L28
func TestFormat_Anthropic(t *testing.T) {
runFormatCases(t, []formatCase{
{
// system as a string; end_turn is stop
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/message_create_params.py#L181
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/stop_reason.py#L5
name: "SystemStringAndText_EndTurn",
input: `{"model":"claude-sonnet-4-5","max_tokens":1024,"system":"You are terse.","messages":[{"role":"user","content":"Hi"}]}`,
output: `{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[{"type":"text","text":"Hello."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":3}}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are terse."}]},
{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}], "finish_reason": "stop"}]`,
},
{
// tool_result sits inside the next user turn, with text beside it
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/tool_result_block_param.py#L29
name: "ToolRound_ToolResultSplitFromUserTurn",
input: `{"messages":[{"role":"user","content":"Weather in Oslo?"},{"role":"assistant","content":[{"type":"text","text":"Checking."},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Oslo"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"{\"temp_c\":4}"},{"type":"text","text":"And Bergen?"}]}]}`,
output: `{"type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_2","name":"get_weather","input":{"city":"Bergen"}}],"stop_reason":"tool_use"}`,
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
{"role": "assistant", "parts": [{"type": "text", "content": "Checking."}, {"type": "tool_call", "id": "toolu_1", "name": "get_weather", "arguments": {"city": "Oslo"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "toolu_1", "response": {"temp_c": 4}}]},
{"role": "user", "parts": [{"type": "text", "content": "And Bergen?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "id": "toolu_2", "name": "get_weather", "arguments": {"city": "Bergen"}}], "finish_reason": "tool_call"}]`,
},
{
// system as text blocks; every image and document source type; thinking blocks in the history and
// the answer
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/image_block_param.py#L14
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/document_block_param.py#L16
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/thinking_block_param.py#L21
name: "MediaSourcesAndThinking_MaxTokens",
input: `{"system":[{"type":"text","text":"Describe inputs.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgo="}},{"type":"image","source":{"type":"url","url":"https://example.com/a.jpg"}},{"type":"document","source":{"type":"file","file_id":"file_1"}},{"type":"document","source":{"type":"text","media_type":"text/plain","data":"plain notes"},"title":"notes"},{"type":"text","text":"What are these?"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"Two images and two documents.","signature":"sig"},{"type":"redacted_thinking","data":"xx"},{"type":"text","text":"Images and documents."}]}]}`,
output: `{"type":"message","role":"assistant","content":[{"type":"thinking","thinking":"Listing them.","signature":"sig"},{"type":"text","text":"A PNG, a JPEG"}],"stop_reason":"max_tokens"}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Describe inputs."}]},
{"role": "user", "parts": [
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "uri", "modality": "image", "uri": "https://example.com/a.jpg"},
{"type": "file", "modality": "document", "file_id": "file_1"},
{"type": "text", "content": "plain notes"},
{"type": "text", "content": "What are these?"}
]},
{"role": "assistant", "parts": [{"type": "reasoning", "content": "Two images and two documents."}, {"type": "reasoning", "content": ""}, {"type": "text", "content": "Images and documents."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "reasoning", "content": "Listing them."}, {"type": "text", "content": "A PNG, a JPEG"}], "finish_reason": "length"}]`,
},
{
// server_tool_use with its web_search_tool_result in the same message; refusal is a content filter
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/server_tool_use_block_param.py#L33
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/web_search_tool_result_block_param.py#L22
// https://github.com/anthropics/anthropic-sdk-python/blob/2a53eaa6966b04560b6a72c28d3ace4389a9ac58/src/anthropic/types/stop_reason.py#L5
name: "ServerToolUseAndResult_Refusal",
output: `{"type":"message","role":"assistant","content":[{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"otel genai"}},{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":[{"type":"web_search_result","url":"https://opentelemetry.io","title":"OTel"}]},{"type":"text","text":"Found it."}],"stop_reason":"refusal"}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "server_tool_call", "id": "srvtoolu_1", "name": "web_search", "server_tool_call": {"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "otel genai"}}},
{"type": "server_tool_call_response", "id": "srvtoolu_1", "server_tool_call_response": {"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{"type": "web_search_result", "url": "https://opentelemetry.io", "title": "OTel"}]}},
{"type": "text", "content": "Found it."}
], "finish_reason": "content_filter"}]`,
},
{
// a request with neither system nor Anthropic-only blocks is an OpenAI chat request; the
// response names the format
name: "PlainRequest_ResponseNamesFormat",
input: `{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"Hi"}]}`,
output: `{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello."}],"stop_reason":"end_turn"}`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}], "finish_reason": "stop"}]`,
},
{
name: "BareMessageList_ToolBlocksNameFormat",
input: `[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":[{"type":"text","text":"sunny"}]}]}]`,
wantInput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "id": "toolu_1", "response": "sunny"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,165 @@
package aiobservabilitytypes
// Format: Gemini generateContent, a request {contents, systemInstruction} or a response {candidates} of
// Content {role, parts} objects, in the REST camelCase or the SDK snake_case spelling:
// https://ai.google.dev/api/generate-content
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2303
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2626
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L8351
// Written by: gateways recording raw Gemini bodies. OpenInference flattens these into llm.*_messages,
// which take precedence.
// Handled: text, thought text, inlineData, fileData, functionCall, functionResponse; a functionResponse
// inside a user turn becomes a tool message before it. Any other part is kept as sent.
// Not yet: a blocked prompt, promptFeedback without candidates.
func isGeminiRequest(m map[string]any) bool {
switch m["contents"].(type) {
case []any, string:
return true
}
return false
}
func isGeminiResponse(m map[string]any) bool {
_, ok := m["candidates"].([]any)
return ok
}
// isGeminiContent tells a bare Content from a semconv message: both carry parts, only semconv parts
// have a type.
func isGeminiContent(m map[string]any) bool {
parts, ok := m["parts"].([]any)
if !ok {
return false
}
first, ok := firstObject(parts)
return ok && first["type"] == nil
}
// geminiRequest reads contents and systemInstruction:
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L6527
func (f *formatting) geminiRequest(m map[string]any) OutputMessages {
var msgs OutputMessages
if system := geminiSystemInstruction(objectAt(m, "systemInstruction", "system_instruction")); system != nil {
msgs = append(msgs, *system)
}
switch contents := m["contents"].(type) {
case string:
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(contents))}})
case []any:
msgs = append(msgs, f.geminiContents(contents)...)
}
return msgs
}
// geminiResponse is one message per candidate with its finish reason.
func (f *formatting) geminiResponse(m map[string]any) OutputMessages {
candidates, _ := m["candidates"].([]any)
out := make(OutputMessages, 0, len(candidates))
for _, item := range candidates {
candidate, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
content, _ := candidate["content"].(map[string]any)
msg := geminiContent(content, RoleAssistant)
msg.FinishReason = finishReason(stringOf(objectAt(candidate, "finishReason", "finish_reason")))
out = append(out, msg)
}
return out
}
// geminiSystemInstruction accepts a string, a Content or a list of parts.
func geminiSystemInstruction(value any) *OutputMessage {
var parts Parts
switch v := value.(type) {
case map[string]any:
parts = geminiContent(v, RoleSystem).Parts
case []any:
parts = geminiParts(v)
default:
parts = systemParts(value)
}
if len(parts) == 0 {
return nil
}
return &OutputMessage{Role: RoleSystem, Parts: parts}
}
func (f *formatting) geminiContents(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
switch v := item.(type) {
case string:
out = append(out, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(v))}})
case map[string]any:
out = append(out, splitToolResults(geminiContent(v, RoleUser))...)
default:
out = append(out, genericMessage(item))
}
}
return out
}
func geminiContent(c map[string]any, fallback Role) OutputMessage {
msg := OutputMessage{Role: role(stringOf(c["role"])), Parts: Parts{}}
if msg.Role.IsZero() {
msg.Role = fallback
}
parts, _ := c["parts"].([]any)
msg.Parts = geminiParts(parts)
return msg
}
func geminiParts(list []any) Parts {
parts := Parts{}
for _, item := range list {
if p, ok := item.(map[string]any); ok {
parts = append(parts, geminiPart(p))
} else {
parts = append(parts, contentPart(item))
}
}
return parts
}
// geminiPart converts one Part by the one field it carries:
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2324
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2112
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L1731
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L1830
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2032
func geminiPart(p map[string]any) Part {
if text, ok := p["text"].(string); ok {
if thought, _ := p["thought"].(bool); thought {
return part(ReasoningPart{Type: PartTypeReasoning, Content: text})
}
return part(textPart(text))
}
if data, ok := objectAt(p, "inlineData", "inline_data").(map[string]any); ok {
mime := stringOf(objectAt(data, "mimeType", "mime_type"))
return part(BlobPart{Type: PartTypeBlob, Modality: modalityOf(mime), MimeType: optionalString(mime), Content: stringOf(data["data"])})
}
if file, ok := objectAt(p, "fileData", "file_data").(map[string]any); ok {
mime := stringOf(objectAt(file, "mimeType", "mime_type"))
return part(UriPart{Type: PartTypeURI, Modality: modalityOf(mime), MimeType: optionalString(mime), URI: stringOf(objectAt(file, "fileUri", "file_uri"))})
}
if call, ok := objectAt(p, "functionCall", "function_call").(map[string]any); ok {
return part(ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(call["id"]), Name: stringOf(call["name"]), Arguments: call["args"]})
}
if response, ok := objectAt(p, "functionResponse", "function_response").(map[string]any); ok {
return part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(response["id"]), Response: response["response"]})
}
return part(GenericPart(p))
}
// objectAt reads the first of the key spellings that is set.
func objectAt(m map[string]any, keys ...string) any {
for _, key := range keys {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}

View File

@@ -0,0 +1,75 @@
package aiobservabilitytypes
import "testing"
// Cases follow the Gemini generateContent shapes, REST camelCase and SDK snake_case; no capture in
// SigNoz/scripts records raw Gemini bodies.
// https://ai.google.dev/api/generate-content
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2303
func TestFormat_Gemini(t *testing.T) {
runFormatCases(t, []formatCase{
{
// systemInstruction as a Content; STOP is stop
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L6527
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L510
name: "SystemInstructionContent_CandidateText_Stop",
input: `{"contents":[{"role":"user","parts":[{"text":"Hi"}]}],"systemInstruction":{"parts":[{"text":"You are terse."}]},"generationConfig":{"temperature":0}}`,
output: `{"candidates":[{"content":{"parts":[{"text":"Hello."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2},"modelVersion":"gemini-2.5-flash"}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are terse."}]},
{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}], "finish_reason": "stop"}]`,
},
{
// SDK snake_case spelling; the function response sits in the next user turn
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L1830
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2032
name: "FunctionRoundSnakeCase_ResponseSplitFromUserTurn",
input: `{"model":"gemini-2.5-pro","contents":[{"role":"user","parts":[{"text":"Weather in Oslo?"}]},{"role":"model","parts":[{"function_call":{"name":"get_weather","args":{"city":"Oslo"}}}]},{"role":"user","parts":[{"function_response":{"name":"get_weather","response":{"temp_c":4}}},{"text":"And Bergen?"}]}],"system_instruction":"Use tools."}`,
output: `{"candidates":[{"content":{"role":"model","parts":[{"function_call":{"id":"fc_1","name":"get_weather","args":{"city":"Bergen"}}}]},"finish_reason":"STOP"}]}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Use tools."}]},
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Oslo"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"temp_c": 4}}]},
{"role": "user", "parts": [{"type": "text", "content": "And Bergen?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "id": "fc_1", "name": "get_weather", "arguments": {"city": "Bergen"}}], "finish_reason": "stop"}]`,
},
{
// inline and file data, a thought part, an executable code part kept as sent; two candidates, the
// second stopped for SAFETY
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2112
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L1731
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L2344
// https://github.com/googleapis/python-genai/blob/78f6d3412caaaed44f99ce57be2d16d0b8c407cb/google/genai/types.py#L510
name: "MediaAndThought_TwoCandidates",
input: `{"contents":[{"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgo="}},{"fileData":{"mimeType":"video/mp4","fileUri":"gs://bucket/a.mp4"}},{"text":"Describe."}]}]}`,
output: `{"candidates":[{"content":{"role":"model","parts":[{"text":"Looking.","thought":true},{"executableCode":{"language":"PYTHON","code":"print(1)"}},{"text":"A logo and a clip."}]},"finishReason":"MAX_TOKENS"},{"content":{"role":"model","parts":[]},"finishReason":"SAFETY"}]}`,
wantInput: `[{"role": "user", "parts": [
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "uri", "modality": "video", "mime_type": "video/mp4", "uri": "gs://bucket/a.mp4"},
{"type": "text", "content": "Describe."}
]}]`,
wantOutput: `[
{"role": "assistant", "parts": [{"type": "reasoning", "content": "Looking."}, {"executableCode": {"language": "PYTHON", "code": "print(1)"}}, {"type": "text", "content": "A logo and a clip."}], "finish_reason": "length"},
{"role": "assistant", "parts": [], "finish_reason": "content_filter"}
]`,
},
{
name: "StringContents_UserText",
input: `{"model":"gemini-2.5-flash","contents":"Hi"}`,
output: `{"role":"model","parts":[{"text":"Hello."}]}`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}]}]`,
},
{
// a bare Content list is told from semconv messages by its untyped parts
name: "BareContentList_NotSemconv",
input: `[{"role":"user","parts":[{"text":"Hi"}]}]`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,200 @@
package aiobservabilitytypes
import "github.com/SigNoz/signoz/pkg/valuer"
// Format: LangChain's serialised run inputs and outputs, {messages: [[Serializable]]} and
// {generations: [[{text, generation_info, message}]]}, each message {lc: 1, type: "constructor",
// id: [..., class], kwargs: {content, tool_calls, tool_call_id, name, ...}}:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/load/serializable.py#L297
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/outputs/llm_result.py#L23
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/ai.py#L170
// Written by: the LangSmith OTel exporter as gen_ai.prompt and gen_ai.completion, which the span mapper
// moves onto gen_ai.input.messages and gen_ai.output.messages:
// https://github.com/langchain-ai/langsmith-sdk/blob/bf325f3a0d9f415096a4632099b7faff355cde53/python/langsmith/_internal/otel/_otel_exporter.py#L761
// https://github.com/langchain-ai/langsmith-sdk/blob/bf325f3a0d9f415096a4632099b7faff355cde53/python/langsmith/_internal/otel/_otel_exporter.py#L843
// The envelope and the message classes are langchain_core's and shared by every partner package; the
// partners differ in the key that carries the finish reason and in the content blocks they keep.
// Handled: string content, langchain_core's standard content blocks and the OpenAI block spellings,
// tool_calls, tool messages, the finish reason of a generation.
// Not yet: the provider-native thinking blocks anthropic, bedrock and google keep in the content, kept
// as sent:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L866
// https://github.com/langchain-ai/langchain-aws/blob/910b79b9787eee773cde65bdd9e94266d6cf6f6c/libs/aws/langchain_aws/chat_models/bedrock_converse.py#L401
// https://github.com/langchain-ai/langchain-google/blob/9f72e9a344235a488396ac2c62d360ceb2b1c81c/libs/genai/langchain_google_genai/chat_models.py#L165
// LangChain message classes to roles; ChatMessage carries its own role in kwargs.
var langChainRoles = map[string]Role{
"SystemMessage": RoleSystem, "SystemMessageChunk": RoleSystem,
"HumanMessage": RoleUser, "HumanMessageChunk": RoleUser,
"AIMessage": RoleAssistant, "AIMessageChunk": RoleAssistant,
"ToolMessage": RoleTool, "ToolMessageChunk": RoleTool, "FunctionMessage": RoleTool, "FunctionMessageChunk": RoleTool,
}
// isLangChainRun looks for the generations list or a messages batch of serialised messages.
func isLangChainRun(m map[string]any) bool {
if _, ok := m["generations"].([]any); ok {
return true
}
batches, ok := m["messages"].([]any)
if !ok || len(batches) == 0 {
return false
}
first, ok := batches[0].([]any)
if !ok {
return false
}
msg, ok := firstObject(first)
return ok && msg["lc"] != nil
}
// langChainRun converts the run inputs, every batch of messages in order, or the run outputs, one
// message per generation.
func (f *formatting) langChainRun(m map[string]any) OutputMessages {
var out OutputMessages
if batches, ok := m["messages"].([]any); ok {
for _, batch := range batches {
if list, ok := batch.([]any); ok {
out = append(out, f.langChainMessages(list)...)
}
}
return out
}
batches, _ := m["generations"].([]any)
for _, batch := range batches {
list, _ := batch.([]any)
for _, item := range list {
generation, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}}
if message, ok := generation["message"].(map[string]any); ok {
msg = f.langChainMessage(message)
} else if text := stringOf(generation["text"]); text != "" {
msg.Parts = Parts{part(textPart(text))}
}
if info, ok := generation["generation_info"].(map[string]any); ok && msg.FinishReason == nil {
msg.FinishReason = finishReason(stringOf(info["finish_reason"]))
}
out = append(out, msg)
}
}
return out
}
func (f *formatting) langChainMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
out = append(out, f.langChainMessage(m))
}
return out
}
// langChainMessage converts one serialised message. A tool_call or tool_use block in the content repeats
// a call that tool_calls already carries, langchain_core adds the former from the latter and
// langchain-anthropic keeps the provider's block beside it:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/ai.py#L274
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L2571
// A failed call leaves the error body in response_metadata.
func (f *formatting) langChainMessage(m map[string]any) OutputMessage {
kwargs, _ := m["kwargs"].(map[string]any)
msg := OutputMessage{Role: langChainRole(m, kwargs), Parts: Parts{}, Name: optionalString(kwargs["name"])}
if id := stringOf(kwargs["tool_call_id"]); msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: toolResponse(kwargs["content"])})}
return msg
}
calls, _ := kwargs["tool_calls"].([]any)
switch content := kwargs["content"].(type) {
case string:
if content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
case []any:
for _, item := range content {
if block, ok := item.(map[string]any); ok && len(calls) > 0 && (block["type"] == "tool_call" || block["type"] == "tool_use") {
continue
}
msg.Parts = append(msg.Parts, langChainBlock(item))
}
}
for _, item := range calls {
if call, ok := item.(map[string]any); ok {
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), ID: optionalString(call["id"]), Arguments: decode(call["args"])}))
}
}
if metadata, ok := kwargs["response_metadata"].(map[string]any); ok {
msg.FinishReason = langChainFinishReason(metadata)
if body, ok := metadata["body"].(map[string]any); ok && body["error"] != nil {
msg.FinishReason = finishReason("error")
}
}
return msg
}
// langChainFinishReason reads the key each partner writes into response_metadata: finish_reason for
// most, stop_reason for anthropic, stopReason for bedrock, done_reason for ollama:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L2510
// https://github.com/langchain-ai/langchain-aws/blob/910b79b9787eee773cde65bdd9e94266d6cf6f6c/libs/aws/langchain_aws/chat_models/bedrock_converse.py#L265
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/ollama/langchain_ollama/chat_models.py#L24
func langChainFinishReason(metadata map[string]any) *FinishReason {
for _, key := range []string{"finish_reason", "stop_reason", "stopReason", "done_reason"} {
if fr := finishReason(stringOf(metadata[key])); fr != nil {
return fr
}
}
return nil
}
// langChainBlock converts one content block: langchain_core's standard reasoning and media blocks, else
// the OpenAI spellings contentPart knows:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L456
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L498
func langChainBlock(item any) Part {
block, ok := item.(map[string]any)
if !ok {
return contentPart(item)
}
typ := stringOf(block["type"])
switch typ {
case "reasoning":
return part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(block["reasoning"])})
case "image", "audio", "video", "file":
modality := Modality{valuer.NewString(typ)}
if typ == "file" {
modality = ModalityDocument
}
mime := optionalString(block["mime_type"])
switch {
case stringOf(block["url"]) != "":
return part(mediaPart(stringOf(block["url"]), modality))
case stringOf(block["base64"]) != "":
return part(BlobPart{Type: PartTypeBlob, Modality: modality, MimeType: mime, Content: stringOf(block["base64"])})
case stringOf(block["file_id"]) != "":
return part(FilePart{Type: PartTypeFile, Modality: modality, MimeType: mime, FileID: stringOf(block["file_id"])})
}
}
return contentPart(item)
}
// langChainRole reads the message class from the serialised id path, falling back to the type
// alias in kwargs:
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/chat.py#L18
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/human.py#L29
func langChainRole(m, kwargs map[string]any) Role {
if id, ok := m["id"].([]any); ok && len(id) > 0 {
class := stringOf(id[len(id)-1])
if r, ok := langChainRoles[class]; ok {
return r
}
if class == "ChatMessage" || class == "ChatMessageChunk" {
return role(stringOf(kwargs["role"]))
}
}
return role(stringOf(kwargs["type"]))
}

View File

@@ -0,0 +1,77 @@
package aiobservabilitytypes
import "testing"
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored them for
// the SigNoz/scripts langsmith capture, the LangSmith OTel exporter's serialised run inputs and outputs;
// the others follow langchain_core and the partner packages at the linked lines.
func TestFormat_LangChain(t *testing.T) {
runFormatCases(t, []formatCase{
{
// langsmith capture, ChatOpenAI span after the tool ran
name: "SerialisedMessages_RolesFromClasses_ToolCallAndToolMessage",
input: `{"messages":[[{"lc":1,"type":"constructor","id":["langchain","schema","messages","SystemMessage"],"kwargs":{"content":"You are a weather assistant. Call get_weather once per city.","type":"system"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","HumanMessage"],"kwargs":{"content":"What's the weather in Bengaluru?","type":"human"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"refusal":null},"response_metadata":{"finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"invalid_tool_calls":[]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","ToolMessage"],"kwargs":{"content":"{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}","type":"tool","tool_call_id":"call_nbrbkc5fom95q8uMA2bCvH6s","status":"success"}}]]}`,
output: `{"generations":[[{"text":"The weather in Bengaluru is clear, 18°C.","generation_info":{"finish_reason":"stop","logprobs":null},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"The weather in Bengaluru is clear, 18°C.","additional_kwargs":{"refusal":null},"response_metadata":{"finish_reason":"stop","model_name":"gpt-4o-mini-2024-07-18"},"type":"ai","tool_calls":[],"invalid_tool_calls":[]}}}]],"llm_output":{"model_name":"gpt-4o-mini-2024-07-18"},"run":null,"type":"LLMResult"}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are a weather assistant. Call get_weather once per city."}]},
{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_nbrbkc5fom95q8uMA2bCvH6s", "arguments": {"city": "Bengaluru"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_nbrbkc5fom95q8uMA2bCvH6s", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The weather in Bengaluru is clear, 18°C."}], "finish_reason": "stop"}]`,
},
{
// langsmith capture, ChatOpenAI span answering with a tool call: the generation text is empty and
// the finish reason sits in generation_info
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/outputs/generation.py#L28
name: "GenerationWithToolCalls_ToolCallPart_FinishFromGenerationInfo",
output: `{"generations":[[{"text":"","generation_info":{"finish_reason":"tool_calls"},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","response_metadata":{},"type":"ai","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_1","type":"tool_call"}]}}}]]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
// langsmith capture, ChatOpenAI span of a failed call: the error body sits in response_metadata
name: "GenerationWithErrorBody_EmptyMessage_FinishError",
output: `{"generations":[[{"text":"","generation_info":null,"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","response_metadata":{"body":{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"},"status":401},"headers":{}},"type":"ai"}}}]]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [], "finish_reason": "error"}]`,
},
{
// a ChatMessage carries its role in kwargs; an AIMessage from langchain-anthropic keeps the tool_use
// block in its content beside tool_calls
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/chat.py#L18
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L2571
name: "ChatMessageRole_ToolUseBlockBesideToolCalls_OneToolCallPart",
input: `{"messages":[[{"lc":1,"type":"constructor","id":["langchain","schema","messages","ChatMessage"],"kwargs":{"role":"developer","content":[{"type":"text","text":"Be brief."}]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":[{"type":"text","text":"Calling."},{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Paris"}}],"tool_calls":[{"name":"get_weather","args":{"city":"Paris"},"id":"toolu_1"}]}}]]}`,
wantInput: `[
{"role": "developer", "parts": [{"type": "text", "content": "Be brief."}]},
{"role": "assistant", "parts": [{"type": "text", "content": "Calling."}, {"type": "tool_call", "name": "get_weather", "id": "toolu_1", "arguments": {"city": "Paris"}}]}
]`,
wantOutput: `[]`,
},
{
// the finish reason under the key each partner writes: anthropic stop_reason, ollama done_reason
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/anthropic/langchain_anthropic/chat_models.py#L2510
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/partners/ollama/langchain_ollama/chat_models.py#L24
name: "PartnerFinishReasonKeys_StopReasonAndDoneReason",
output: `{"generations":[[{"text":"Hi.","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"Hi.","response_metadata":{"stop_reason":"end_turn","model":"claude-sonnet-4-5"}}}}],[{"text":"Hel","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"Hel","response_metadata":{"model":"llama3","done_reason":"length"}}}}]]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hi."}], "finish_reason": "stop"}, {"role": "assistant", "parts": [{"type": "text", "content": "Hel"}], "finish_reason": "length"}]`,
},
{
// langchain_core's standard content blocks, as partners write them under output_version v1
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L456
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L498
// https://github.com/langchain-ai/langchain/blob/84ba23c414eca6bf6f6ebd1394a3006378113540/libs/core/langchain_core/messages/content.py#L721
name: "StandardContentBlocks_ReasoningAndMediaParts",
output: `{"generations":[[{"text":"It is a duck.","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":[{"type":"reasoning","reasoning":"Looks like a bird."},{"type":"text","text":"It is a duck."},{"type":"image","base64":"iVBORw0KGgo=","mime_type":"image/png"},{"type":"file","url":"https://example.com/a.pdf","mime_type":"application/pdf"}],"response_metadata":{"finish_reason":"stop","output_version":"v1"}}}}]]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "reasoning", "content": "Looks like a bird."},
{"type": "text", "content": "It is a duck."},
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "uri", "modality": "document", "uri": "https://example.com/a.pdf"}
], "finish_reason": "stop"}]`,
},
})
}

View File

@@ -167,7 +167,8 @@ func TestFormat_OpenAIChat(t *testing.T) {
]`,
},
{
// openinference capture, input.value of the ChatCompletion span
// ChatCompletionRequestMessageContentPartFile {file: {file_id}}, as OpenInference records the request in input.value:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L42650
name: "FileByID_FilePart",
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"file_id": "file-6F2ksmvXxt4VdoqmHRw6kL"}}]}], "max_tokens": 60}`,
output: `{"id":"chatcmpl-DktFileCapture0001","object":"chat.completion","model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"The document is a short PDF about dragons.","refusal":null,"annotations":[]}}]}`,
@@ -189,7 +190,8 @@ func TestFormat_OpenAIChat(t *testing.T) {
]`,
},
{
// openinference capture, input.value of the ChatCompletion span
// ChatCompletionRequestMessageContentPartFile {file: {filename, file_data}}, file_data a data URL:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L42650
name: "FileInline_BlobPart",
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"filename": "draconomicon.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbXS9Db3VudCAwPj5lbmRvYmoKdHJhaWxlcjw8L1Jvb3QgMSAwIFI+PgolJUVPRgo="}}]}], "max_tokens": 60}`,
wantInput: `[

View File

@@ -0,0 +1,136 @@
package aiobservabilitytypes
import "strings"
// Format: OpenAI Responses, a request {input, instructions} or a response {output, status} of items:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L47597
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53834
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57596
// Written by: gateways recording raw Responses bodies, and the raw calls OpenInference records for the
// Responses API and the OpenAI Agents SDK when its flattened messages are absent.
// Handled: message items with input_text, output_text, refusal, input_image and input_file content;
// function_call and custom_tool_call with their outputs; reasoning summaries; compaction; every other
// *_call item as a server tool call and *_output item as its response; the status and incomplete_details
// of a response.
// Dropped: reasoning encrypted_content, item ids. Not yet: input_audio content.
// isResponsesRequest tells a Responses request from an embeddings one, which also carries input and
// model: items are objects, and only Responses takes instructions or tools.
func isResponsesRequest(m map[string]any) bool {
if m["input"] == nil {
return false
}
if m["instructions"] != nil || m["tools"] != nil || m["previous_response_id"] != nil {
return true
}
items, ok := m["input"].([]any)
if !ok {
return false
}
_, isItem := firstObject(items)
return isItem
}
func isResponsesResponse(m map[string]any) bool {
if _, ok := m["output"].([]any); !ok {
return false
}
return stringOf(m["object"]) == "response" || m["status"] != nil
}
func (f *formatting) responsesRequest(m map[string]any) OutputMessages {
var msgs OutputMessages
if parts := systemParts(m["instructions"]); len(parts) > 0 {
msgs = append(msgs, OutputMessage{Role: RoleSystem, Parts: parts})
}
switch input := m["input"].(type) {
case string:
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(input))}})
case []any:
msgs = append(msgs, foldAssistant(f.responsesItems(input))...)
}
return msgs
}
// responsesResponse folds the output items into one message: the API returns a single choice. The
// finish reason is the incomplete_details reason, else the status:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69763
func (f *formatting) responsesResponse(m map[string]any) OutputMessages {
output, _ := m["output"].([]any)
msgs := foldAssistant(f.responsesItems(output))
if len(msgs) == 0 {
return msgs
}
last := &msgs[len(msgs)-1]
if details, ok := m["incomplete_details"].(map[string]any); ok {
last.FinishReason = finishReason(stringOf(details["reason"]))
} else if status := stringOf(m["status"]); status != "" {
last.FinishReason = finishReason(status)
}
return msgs
}
// responsesItems converts items one message each; the caller folds assistant runs.
func (f *formatting) responsesItems(items []any) OutputMessages {
msgs := make(OutputMessages, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
if s, ok := item.(string); ok {
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(s))}})
continue
}
msgs = append(msgs, genericMessage(item))
continue
}
typ := stringOf(m["type"])
switch {
case typ == "" || typ == "message":
msgs = append(msgs, f.openAIChatMessage(m))
case typ == "function_call" || typ == "custom_tool_call":
args := m["arguments"]
if args == nil {
args = m["input"]
}
call := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(m["name"]), ID: optionalString(m["call_id"]), Arguments: decode(args)}
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(call)}})
case typ == "function_call_output" || typ == "custom_tool_call_output":
result := ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(m["call_id"]), Response: toolResponse(m["output"])}
msgs = append(msgs, OutputMessage{Role: RoleTool, Parts: Parts{part(result)}})
case typ == "reasoning":
if p, ok := reasoningItem(m); ok {
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{p}})
}
case typ == "compaction":
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(CompactionPart{Type: PartTypeCompaction, ID: optionalString(m["id"]), Content: optionalString(m["content"])})}})
case strings.HasSuffix(typ, "_call"):
call := ServerToolCallPart{Type: PartTypeServerToolCall, Name: strings.TrimSuffix(typ, "_call"), ID: optionalString(m["call_id"]), ServerToolCall: GenericServerToolCall(m)}
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(call)}})
case strings.HasSuffix(typ, "_output") || typ == "mcp_approval_response":
result := ServerToolCallResponsePart{Type: PartTypeServerToolCallResponse, ID: optionalString(m["call_id"]), ServerToolCallResponse: GenericServerToolCallResponse(m)}
msgs = append(msgs, OutputMessage{Role: RoleTool, Parts: Parts{part(result)}})
default:
msgs = append(msgs, OutputMessage{Role: RoleAssistant, Parts: Parts{part(GenericPart(m))}})
}
}
return msgs
}
// reasoningItem joins the summary texts. An item with neither a summary nor encrypted content
// carries nothing to show:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69620
func reasoningItem(m map[string]any) (Part, bool) {
var texts []string
if summary, ok := m["summary"].([]any); ok {
for _, item := range summary {
if s, ok := item.(map[string]any); ok && stringOf(s["text"]) != "" {
texts = append(texts, stringOf(s["text"]))
}
}
}
if len(texts) == 0 && stringOf(m["encrypted_content"]) == "" {
return Part{}, false
}
return part(ReasoningPart{Type: PartTypeReasoning, Content: strings.Join(texts, "\n")}), true
}

View File

@@ -0,0 +1,85 @@
package aiobservabilitytypes
import "testing"
// Cases follow the OpenAI Responses schemas; the first mirrors the raw bodies in the SigNoz/scripts
// openinference-openai-agents capture, which OpenInference's flattened messages otherwise cover.
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L47597
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
func TestFormat_OpenAIResponses(t *testing.T) {
runFormatCases(t, []formatCase{
{
// openinference-openai-agents capture, Response span: instructions outside the history, a reasoning
// item without a summary, then the message
name: "InstructionsAndStringInput_ReasoningWithoutSummaryDropped",
input: `{"include": [], "input": [{"content": "A bat and a ball cost $1.10. How much is the ball?", "role": "user"}], "instructions": "You solve problems step by step and give a final answer.", "model": "o3-mini", "tools": []}`,
output: `{"id":"resp_1","object":"response","status":"completed","model":"o3-mini-2025-01-31","output":[{"id":"rs_1","summary":[],"type":"reasoning"},{"id":"msg_1","content":[{"annotations":[],"text":"Final Answer: The ball costs $0.05.","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message"}],"usage":{"input_tokens":49,"output_tokens":431}}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You solve problems step by step and give a final answer."}]},
{"role": "user", "parts": [{"type": "text", "content": "A bat and a ball cost $1.10. How much is the ball?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Final Answer: The ball costs $0.05."}], "finish_reason": "stop"}]`,
},
{
// an InputMessage with every InputContent type, then function and custom tool calls with their
// outputs, a compaction item and a computer_call_output
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53827
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L54142
name: "InputItems_EveryContentType_FunctionCallRound",
input: `{"model":"gpt-4o-mini","input":[{"role":"user","content":[{"type":"input_text","text":"What is in this image and these files?"},{"type":"input_image","image_url":"https://example.com/duck.jpg","detail":"auto"},{"type":"input_image","file_id":"file-img","detail":"low"},{"type":"input_file","file_id":"file-abc"},{"type":"input_file","filename":"a.pdf","file_data":"data:application/pdf;base64,JVBERi0="},{"type":"input_file","file_url":"https://example.com/a.pdf"}]},{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}"},{"type":"function_call_output","call_id":"call_1","output":"{\"temp_c\":18}"},{"type":"custom_tool_call","call_id":"c1","name":"shell","input":"ls"},{"type":"custom_tool_call_output","call_id":"c1","output":"a.txt"},{"type":"compaction","id":"cmp_1","encrypted_content":"x"},{"type":"computer_call_output","call_id":"cc_1","output":{"type":"computer_screenshot","image_url":"data:image/png;base64,iVBORw0KGgo="}}]}`,
wantInput: `[
{"role": "user", "parts": [
{"type": "text", "content": "What is in this image and these files?"},
{"type": "uri", "modality": "image", "uri": "https://example.com/duck.jpg"},
{"type": "file", "modality": "image", "file_id": "file-img"},
{"type": "file", "modality": "document", "file_id": "file-abc", "mime_type": "application/pdf"},
{"type": "blob", "modality": "document", "mime_type": "application/pdf", "content": "JVBERi0="},
{"type": "uri", "modality": "document", "uri": "https://example.com/a.pdf"}
]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": {"temp_c": 18}}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "shell", "id": "c1", "arguments": "ls"}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "c1", "response": "a.txt"}]},
{"role": "assistant", "parts": [{"type": "compaction", "id": "cmp_1"}]},
{"role": "tool", "parts": [{"type": "server_tool_call_response", "id": "cc_1", "server_tool_call_response": {"type": "computer_call_output", "call_id": "cc_1", "output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,iVBORw0KGgo="}}}]}
]`,
wantOutput: `[]`,
},
{
// output items folded into the single choice: a ReasoningItem summary, a WebSearchToolCall, the
// OutputMessage with output_text and refusal, then a FunctionToolCall
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57596
name: "OutputItems_FoldedIntoOneMessage_StatusCompleted",
output: `{"id":"resp_2","object":"response","status":"completed","output":[{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"Need the weather first."}],"encrypted_content":"gAAA"},{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"weather Paris"}},{"id":"msg_1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Checking.","annotations":[]},{"type":"refusal","refusal":"I cannot do that."}]},{"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"Paris\"}","id":"fc_1","status":"completed"}]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "reasoning", "content": "Need the weather first."},
{"type": "server_tool_call", "name": "web_search", "server_tool_call": {"id": "ws_1", "type": "web_search_call", "status": "completed", "action": {"type": "search", "query": "weather Paris"}}},
{"type": "text", "content": "Checking."},
{"type": "text", "content": "I cannot do that."},
{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}}
], "finish_reason": "stop"}]`,
},
{
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69763
name: "IncompleteResponse_FinishFromIncompleteDetails",
output: `{"object":"response","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Partial"}]}]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Partial"}], "finish_reason": "length"}]`,
},
{
name: "StringInput_UserMessage",
input: `{"model":"gpt-4o-mini","input":"Say hi","instructions":"Be brief."}`,
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Be brief."}]}, {"role": "user", "parts": [{"type": "text", "content": "Say hi"}]}]`,
wantOutput: `[]`,
},
{
// openinference capture, CreateEmbeddings span: an embeddings request also has input and model
name: "EmbeddingsRequest_NotResponses_KeptAsGeneric",
input: `{"input": ["observability is the new monitoring"], "model": "text-embedding-3-small", "encoding_format": "base64"}`,
warnings: []string{"message format not recognised, kept as generic"},
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"encoding_format\":\"base64\",\"input\":[\"observability is the new monitoring\"],\"model\":\"text-embedding-3-small\"}"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,137 @@
package aiobservabilitytypes
import "github.com/SigNoz/signoz/pkg/valuer"
// Format: the flattened llm.input_messages.N.message.* and llm.output_messages.N.message.* keys, the
// provider-neutral copy of the conversation the OpenInference instrumentations write on LLM spans. A side
// without them falls back to the raw provider payload the mapper moved onto gen_ai.*.messages; pipecat
// streams the reply into output.value alone.
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/semantic_conventions.md#L77
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L37
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L35
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L181
// Handled: message.content, the ordered message.contents items (text, image, audio, video, reasoning),
// message.tool_calls, the legacy message.function_call_*, tool results, llm.finish_reason. A tool_use
// content item repeats a call from message.tool_calls and is skipped.
// Dropped: reasoning id, signature, data and encrypted_content; audio transcripts.
const (
openInferenceInputMessages = "llm.input_messages"
openInferenceOutputMessages = "llm.output_messages"
openInferenceFinishReason = "llm.finish_reason"
)
func (f *formatting) openInference() bool {
input := indexed(f.attributes, openInferenceInputMessages)
output := indexed(f.attributes, openInferenceOutputMessages)
if len(input) == 0 && len(output) == 0 {
return false
}
inputLabel, outputLabel := FormatterOpenInference, FormatterOpenInference
if len(input) > 0 {
f.out.Input = inputMessages(openInferenceMessages(input))
} else if raw := attribute(f.attributes, GenAIInputMessages); raw != nil {
var msgs OutputMessages
msgs, inputLabel = f.side(decode(raw), RoleUser)
f.out.Input = inputMessages(msgs)
}
if len(output) > 0 {
f.out.Output = openInferenceMessages(output)
if fr := finishReason(stringOf(attribute(f.attributes, openInferenceFinishReason))); fr != nil {
for i := range f.out.Output {
f.out.Output[i].FinishReason = fr
}
}
} else if raw := attribute(f.attributes, GenAIOutputMessages); raw != nil {
f.out.Output, outputLabel = f.side(decode(raw), RoleAssistant)
}
f.warnMixedFormats(inputLabel, outputLabel)
return true
}
// openInferenceMessages converts the message buckets and folds runs of assistant messages: the Responses
// path writes one message per output item, an empty one for a reasoning item without a summary.
func openInferenceMessages(buckets []map[string]any) OutputMessages {
msgs := make(OutputMessages, 0, len(buckets))
for _, bucket := range buckets {
message, _ := bucket["message"].(map[string]any)
msgs = append(msgs, openInferenceMessage(message))
}
return foldAssistant(msgs)
}
// openInferenceMessage converts one message.* object. A message without a role is a tool response when
// it has a tool_call_id, an assistant turn when it has tool calls, else a user turn, as the
// collector-contrib normalizer decides it:
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L473
func openInferenceMessage(m map[string]any) OutputMessage {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"])}
id := stringOf(m["tool_call_id"])
calls := indexed(m, "tool_calls")
if msg.Role.IsZero() {
switch {
case id != "":
msg.Role = RoleTool
case len(calls) > 0:
msg.Role = RoleAssistant
default:
msg.Role = RoleUser
}
}
if msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: decode(m["content"])})}
return msg
}
if content := stringOf(m["content"]); content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
msg.Parts = append(msg.Parts, openInferenceContents(m)...)
for _, call := range calls {
if c, ok := call["tool_call"].(map[string]any); ok {
msg.Parts = append(msg.Parts, openInferenceToolCall(c))
}
}
if name := stringOf(m["function_call_name"]); name != "" {
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: name, Arguments: decode(m["function_call_arguments_json"])}))
}
return msg
}
// openInferenceContents converts the ordered message.contents items. A text item may come without
// message_content.type, as the collector-contrib normalizer reads it:
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L285
func openInferenceContents(m map[string]any) Parts {
items := indexed(m, "contents")
parts := make(Parts, 0, len(items))
for _, item := range items {
content, _ := item["message_content"].(map[string]any)
typ := stringOf(content["type"])
if typ == "" && content["text"] != nil {
typ = "text"
}
switch typ {
case "text":
parts = append(parts, part(textPart(stringOf(content["text"]))))
case "reasoning":
parts = append(parts, part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(content["text"])}))
case "image", "audio", "video":
if url := stringOf(attribute(content, typ+"."+typ+".url")); url != "" {
parts = append(parts, part(mediaPart(url, Modality{valuer.NewString(typ)})))
}
case "tool_use":
default:
parts = append(parts, part(GenericPart(content)))
}
}
return parts
}
// openInferenceToolCall reads a tool_call.* object, {id, function: {name, arguments}}.
func openInferenceToolCall(call map[string]any) Part {
fn, _ := call["function"].(map[string]any)
p := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(fn["name"]), ID: optionalString(call["id"])}
if args, ok := fn["arguments"]; ok {
p.Arguments = decode(args)
}
return part(p)
}

View File

@@ -0,0 +1,324 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the llm.input_messages.N.* and llm.output_messages.N.* keys as the collector
// stored them for the SigNoz/scripts static-telemetry-generator captures.
func TestFormat_OpenInference(t *testing.T) {
runFormatCases(t, []formatCase{
{
// openinference capture, ChatCompletion span: the flattened keys win over the raw request the
// mapper moved onto gen_ai.input.messages
name: "FlattenedMessages_RawRequestIgnored",
attributes: map[string]any{
"llm.input_messages.0.message.role": "system",
"llm.input_messages.0.message.content": "You are concise.",
"llm.input_messages.1.message.role": "user",
"llm.input_messages.1.message.content": "Say hello in five words.",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "Hello! How are you today?",
"llm.finish_reason": "stop",
GenAIInputMessages: `{"messages": [{"role": "system", "content": "You are concise."}, {"role": "user", "content": "Say hello in five words."}], "model": "gpt-4o-mini", "max_tokens": 50, "temperature": 0.2}`,
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello! How are you today?"}], "finish_reason": "stop"}]`,
},
{
// openinference capture, ChatCompletion span: message.tool_calls and llm.finish_reason
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L37
name: "ToolCalls_ToolCallParts_FinishReasonFromLLMKey",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "What is the weather in Bengaluru in celsius?",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.tool_calls.0.tool_call.id": "call_MdYKTRrkshuBG9W5oBXYZaRc",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.name": "get_current_weather",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments": `{"city":"Bengaluru","unit":"c"}`,
"llm.finish_reason": "tool_calls",
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_MdYKTRrkshuBG9W5oBXYZaRc", "arguments": {"city": "Bengaluru", "unit": "c"}}], "finish_reason": "tool_call"}]`,
},
{
// openinference capture, the follow-up ChatCompletion span: tool role messages carry the result
// in message.content with their tool_call_id
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L50
name: "ToolRoleMessages_ToolCallResponseParts",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "What's the weather in Paris and in London? Use the tool for each.",
"llm.input_messages.1.message.role": "assistant",
"llm.input_messages.1.message.tool_calls.0.tool_call.id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.name": "get_current_weather",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.input_messages.1.message.tool_calls.1.tool_call.id": "call_si8wiETYGpooMB7gMXSgMQpk",
"llm.input_messages.1.message.tool_calls.1.tool_call.function.name": "get_current_weather",
"llm.input_messages.1.message.tool_calls.1.tool_call.function.arguments": `{"city": "London"}`,
"llm.input_messages.2.message.role": "tool",
"llm.input_messages.2.message.tool_call_id": "call_SW4hst7nzysgvWSAz4OdyK9D",
"llm.input_messages.2.message.content": `{"city": "Paris", "temp_c": 18, "summary": "Clear"}`,
"llm.input_messages.3.message.role": "tool",
"llm.input_messages.3.message.tool_call_id": "call_si8wiETYGpooMB7gMXSgMQpk",
"llm.input_messages.3.message.content": `{"city": "London", "temp_c": 18, "summary": "Clear"}`,
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear",
"llm.finish_reason": "stop",
},
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Paris and in London? Use the tool for each."}]},
{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_current_weather", "id": "call_SW4hst7nzysgvWSAz4OdyK9D", "arguments": {"city": "Paris"}},
{"type": "tool_call", "name": "get_current_weather", "id": "call_si8wiETYGpooMB7gMXSgMQpk", "arguments": {"city": "London"}}
]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_SW4hst7nzysgvWSAz4OdyK9D", "response": {"city": "Paris", "temp_c": 18, "summary": "Clear"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_si8wiETYGpooMB7gMXSgMQpk", "response": {"city": "London", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear"}], "finish_reason": "stop"}]`,
},
{
// openinference capture, vision call; base64 trimmed. An image as a data URL:
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L47
name: "ContentsTextAndImage_TextAndBlobParts",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "text",
"llm.input_messages.0.message.contents.0.message_content.text": "What animal is in this image? One sentence.",
"llm.input_messages.0.message.contents.1.message_content.type": "image",
"llm.input_messages.0.message.contents.1.message_content.image.image.url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "The animal in the image is a duck.",
"llm.finish_reason": "stop",
},
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is in this image? One sentence."},
{"type": "blob", "modality": "image", "mime_type": "image/jpeg", "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD"}
]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The animal in the image is a duck."}], "finish_reason": "stop"}]`,
},
{
// openinference-openai-agents capture, Response span: one output message per item, an empty one
// for a reasoning item without a summary
name: "ResponsesOutputItems_AssistantMessagesFolded_EmptyDropped",
attributes: map[string]any{
"llm.input_messages.0.message.role": "system",
"llm.input_messages.0.message.content": "You solve problems step by step and give a final answer.",
"llm.input_messages.1.message.role": "user",
"llm.input_messages.1.message.content": "A bat and a ball cost $1.10. How much is the ball?",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.1.message.role": "assistant",
"llm.output_messages.1.message.contents.0.message_content.type": "text",
"llm.output_messages.1.message.contents.0.message_content.text": "Final Answer: The ball costs $0.05.",
"llm.output_messages.2.message.role": "assistant",
"llm.output_messages.2.message.tool_calls.0.tool_call.id": "call_1",
"llm.output_messages.2.message.tool_calls.0.tool_call.function.name": "done",
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You solve problems step by step and give a final answer."}]},
{"role": "user", "parts": [{"type": "text", "content": "A bat and a ball cost $1.10. How much is the ball?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Final Answer: The ball costs $0.05."},
{"type": "tool_call", "name": "done", "id": "call_1"}
]}]`,
},
{
// openinference capture, the ChatCompletion span of a failed call
name: "InputOnly_NoOutput",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "this should fail",
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "this should fail"}]}]`,
wantOutput: `[]`,
},
{
// the strands processor keeps the SDK's gen_ai.system_instructions on the span and inserts the same
// prompt as message 0
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
name: "StrandsSystemInstructionsRepeated_OneSystemMessage",
attributes: map[string]any{
"llm.input_messages.0.message.role": "system",
"llm.input_messages.0.message.content": "You are concise.",
"llm.input_messages.1.message.role": "user",
"llm.input_messages.1.message.content": "hi",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "Hello.",
GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`,
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello."}]}]`,
},
{
// pipecat writes the request as llm.input_messages and the streamed reply as output.value alone,
// which the mapper moves onto gen_ai.output.messages
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_attributes.py#L430
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_observer.py#L628
name: "OutputKeysAbsent_RawOutputFormatted_Warns",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "hi",
GenAIOutputMessages: "Hello there.",
},
warnings: []string{"bare assistant text, role assumed", "output formatted as text, input as openinference"},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello there."}]}]`,
},
{
// message.role left unset by some instrumentors: tool_call_id, tool_calls or neither decide it,
// as the collector-contrib normalizer does
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L473
name: "MissingRole_InferredFromToolFields",
attributes: map[string]any{
"llm.input_messages.0.message.content": "Weather in Oslo?",
"llm.input_messages.1.message.tool_calls.0.tool_call.id": "call_9",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.name": "get_weather",
"llm.input_messages.1.message.tool_calls.0.tool_call.function.arguments": `{"city":"Oslo"}`,
"llm.input_messages.2.message.tool_call_id": "call_9",
"llm.input_messages.2.message.content": "4C, cloudy",
},
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_9", "arguments": {"city": "Oslo"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_9", "response": "4C, cloudy"}]}
]`,
wantOutput: `[]`,
},
{
// message_content.type left unset on text items by some instrumentors, as the collector-contrib
// normalizer reads them
// https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/fc79b95297776dec6c47490cd6f6026b942bf6fb/processor/genainormalizerprocessor/internal/openinference/messages.go#L285
name: "UntypedContentWithText_TextPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.text": "Summarise this.",
"llm.input_messages.0.message.contents.1.message_content.text": "Second block.",
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Summarise this."}, {"type": "text", "content": "Second block."}]}]`,
wantOutput: `[]`,
},
{
// an image given by URL
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L42
name: "ImageURL_UriPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "image",
"llm.input_messages.0.message.contents.0.message_content.image.image.url": "https://example.com/duck.jpg",
},
wantInput: `[{"role": "user", "parts": [{"type": "uri", "modality": "image", "uri": "https://example.com/duck.jpg"}]}]`,
wantOutput: `[]`,
},
{
// audio by URL with its transcript, or as the data URI built from a Chat Completions input_audio
// block; the transcript has no part to go to
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/multimodal_attributes.md#L55
name: "Audio_UriAndBlobParts_TranscriptDropped",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "audio",
"llm.input_messages.0.message.contents.0.message_content.audio.audio.url": "https://example.com/hello.mp3",
"llm.input_messages.0.message.contents.0.message_content.audio.audio.transcript": "Hello, how are you?",
"llm.input_messages.0.message.contents.1.message_content.type": "audio",
"llm.input_messages.0.message.contents.1.message_content.audio.audio.url": "data:audio/wav;base64,UklGRg==",
},
wantInput: `[{"role": "user", "parts": [
{"type": "uri", "modality": "audio", "uri": "https://example.com/hello.mp3"},
{"type": "blob", "modality": "audio", "mime_type": "audio/wav", "content": "UklGRg=="}
]}]`,
wantOutput: `[]`,
},
{
// reasoning items; id, signature, data and encrypted_content have no field on the reasoning part
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L181
name: "Reasoning_ReasoningParts_ProviderKeysDropped",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.contents.0.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.0.message_content.text": "The ball must cost less than a dollar.",
"llm.output_messages.0.message.contents.0.message_content.signature": "EuYBCkQYAiJA",
"llm.output_messages.0.message.contents.1.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.1.message_content.data": "EmwKAhgBEgy3",
"llm.output_messages.0.message.contents.2.message_content.type": "reasoning",
"llm.output_messages.0.message.contents.2.message_content.id": "rs_abc123",
"llm.output_messages.0.message.contents.2.message_content.encrypted_content": "gAAAAA==",
"llm.output_messages.0.message.contents.3.message_content.type": "text",
"llm.output_messages.0.message.contents.3.message_content.text": "The ball costs 5 cents.",
},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "reasoning", "content": "The ball must cost less than a dollar."},
{"type": "reasoning", "content": ""},
{"type": "reasoning", "content": ""},
{"type": "text", "content": "The ball costs 5 cents."}
]}]`,
},
{
// a tool call repeated as an ordered tool_use content item; message.tool_calls stays the one
// source. llm.finish_reason is Anthropic's stop_reason as the instrumentation passes it through
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/tool_calling.md#L63
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-anthropic/src/openinference/instrumentation/anthropic/_wrappers.py#L938
name: "ToolUseBesideToolCalls_OneToolCallPart",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.contents.0.message_content.type": "text",
"llm.output_messages.0.message.contents.0.message_content.text": "Let me check.",
"llm.output_messages.0.message.contents.1.message_content.type": "tool_use",
"llm.output_messages.0.message.contents.1.tool_call.id": "toolu_01",
"llm.output_messages.0.message.contents.1.tool_call.function.name": "get_weather",
"llm.output_messages.0.message.contents.1.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.output_messages.0.message.tool_calls.0.tool_call.id": "toolu_01",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.name": "get_weather",
"llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments": `{"city": "Paris"}`,
"llm.finish_reason": "tool_use",
},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Let me check."},
{"type": "tool_call", "name": "get_weather", "id": "toolu_01", "arguments": {"city": "Paris"}}
], "finish_reason": "tool_call"}]`,
},
{
// a content type the spec does not name is kept as sent
name: "UnknownContentType_GenericPart",
attributes: map[string]any{
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.contents.0.message_content.type": "document",
"llm.input_messages.0.message.contents.0.message_content.document.document.id": "file_1",
},
wantInput: `[{"role": "user", "parts": [{"type": "document", "document": {"document": {"id": "file_1"}}}]}]`,
wantOutput: `[]`,
},
{
// the legacy function_call keys
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/semantic_conventions.md#L111
name: "LegacyFunctionCall_ToolCallPart",
attributes: map[string]any{
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.function_call_name": "multiply",
"llm.output_messages.0.message.function_call_arguments_json": `{"x": 2}`,
},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "multiply", "arguments": {"x": 2}}]}]`,
},
{
// a tool result with message.name naming the function
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/llm_spans.md#L50
name: "ToolResultWithName_NameKept",
attributes: map[string]any{
"llm.input_messages.0.message.role": "tool",
"llm.input_messages.0.message.name": "multiply",
"llm.input_messages.0.message.tool_call_id": "call_62136355",
"llm.input_messages.0.message.content": "2001",
},
wantInput: `[{"role": "tool", "name": "multiply", "parts": [{"type": "tool_call_response", "id": "call_62136355", "response": "2001"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -0,0 +1,47 @@
package aiobservabilitytypes
import "strings"
// Format: the {completion, reasoning, tools, rawRequest} answer object OpenRouter's broadcast writes as
// gen_ai.completion on its LLM Generation spans, beside the OpenAI chat {messages} request it writes as
// gen_ai.prompt; the span mapper moves both onto gen_ai.*.messages. A tool call arrives as the tool name
// followed by its JSON arguments in completion, so it is split against the names in tools.
// https://openrouter.ai/docs/guides/features/broadcast/otel-collector
func isOpenRouterCompletion(m map[string]any) bool {
_, ok := m["completion"].(string)
return ok
}
func (f *formatting) openRouterCompletion(m map[string]any) OutputMessages {
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}}
if reasoning := stringOf(m["reasoning"]); reasoning != "" {
msg.Parts = append(msg.Parts, part(ReasoningPart{Type: PartTypeReasoning, Content: reasoning}))
}
completion := stringOf(m["completion"])
if call, ok := openRouterToolCall(completion, m["tools"]); ok {
msg.Parts = append(msg.Parts, call)
msg.FinishReason = finishReason("tool_calls")
} else if completion != "" {
msg.Parts = append(msg.Parts, part(textPart(completion)))
}
return OutputMessages{msg}
}
func openRouterToolCall(completion string, tools any) (Part, bool) {
list, _ := tools.([]any)
for _, item := range list {
tool, _ := item.(map[string]any)
fn, _ := tool["function"].(map[string]any)
name := stringOf(fn["name"])
if name == "" || !strings.HasPrefix(completion, name) {
continue
}
args, ok := decode(strings.TrimPrefix(completion, name)).(map[string]any)
if !ok {
continue
}
return part(ToolCallRequestPart{Type: PartTypeToolCall, Name: name, Arguments: args}), true
}
return Part{}, false
}

View File

@@ -0,0 +1,35 @@
package aiobservabilitytypes
import "testing"
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored them for
// the SigNoz/scripts openrouter-gateway capture, LLM Generation spans.
func TestFormat_OpenRouter(t *testing.T) {
runFormatCases(t, []formatCase{
{
// the completion is the tool name followed by the JSON arguments
name: "CompletionIsToolNameAndArguments_ToolCallPart",
input: `{"messages":[{"role":"user","content":"What's the weather in Bengaluru? Use the tool."}]}`,
output: `{"completion":"get_current_weather{\"city\":\"Bengaluru\"}","reasoning":null,"tools":[{"type":"function","function":{"name":"get_current_weather","description":"Get the current weather for a city.","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}],"rawRequest":{"model":"openai/gpt-4o-mini","max_tokens":80,"tool_choice":"auto"}}`,
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru? Use the tool."}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
name: "CompletionWithReasoning_ReasoningThenText",
input: `{"messages":[{"role":"system","content":"You are concise."},{"role":"user","content":"Say hello in five words."}]}`,
output: `{"completion":"Hello there, how are you?","reasoning":"A greeting of exactly five words.","tools":[],"rawRequest":{"model":"openai/gpt-4o-mini"}}`,
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "reasoning", "content": "A greeting of exactly five words."}, {"type": "text", "content": "Hello there, how are you?"}]}]`,
},
{
// a completion that only starts with a tool name stays text
name: "CompletionStartsWithToolNameButNoArguments_Text",
output: `{"completion":"get_current_weather is the tool to call.","reasoning":null,"tools":[{"type":"function","function":{"name":"get_current_weather"}}]}`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "get_current_weather is the tool to call."}]}]`,
},
})
}

View File

@@ -6,15 +6,20 @@ import (
)
// Format: the OTel shape itself, [{role, parts}].
// Written by: litellm, langchain, openllmetry, portkey, traceloop LLM spans.
// Decoding is the genai types. Repairs: "text" used for "content" on text parts; OpenAI content
// blocks left inside parts.
// Not yet: "thinking" for "reasoning"; "result" or "output" for "response" on tool call responses.
// Written by: litellm, langchain, openllmetry, portkey, traceloop LLM spans, pydantic-ai.
// Decoding is the genai types. Repairs: "text" used for "content" on text parts; OpenAI content blocks
// left inside parts; tool call arguments as a JSON string and "result" for "response", as pydantic-ai
// writes them:
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L3130
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L1724
// The choice finish_reason as the provider spells it, as litellm copies it:
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2822
// Not yet: "thinking" for "reasoning".
// semconv decodes messages that already follow the schema through the genai types. A part of a
// type the schema does not name is a provider content block, which contentPart converts.
func (f *formatting) semconv(list []any) OutputMessages {
data, err := json.Marshal(semconvTextKey(list))
data, err := json.Marshal(semconvRepairs(list))
if err != nil {
f.warn("messages could not be encoded: %s", err)
return OutputMessages{genericMessage(list)}
@@ -28,6 +33,9 @@ func (f *formatting) semconv(list []any) OutputMessages {
if msgs[i].Parts == nil {
msgs[i].Parts = Parts{}
}
if msgs[i].FinishReason != nil {
msgs[i].FinishReason = finishReason(msgs[i].FinishReason.StringValue())
}
for j, p := range msgs[i].Parts {
if block, ok := p.Value.(GenericPart); ok {
msgs[i].Parts[j] = contentPart(map[string]any(block))
@@ -37,9 +45,9 @@ func (f *formatting) semconv(list []any) OutputMessages {
return msgs
}
// semconvTextKey copies the text and reasoning parts some SDKs write with "text" in place of the
// schema's "content". The input is left untouched, it is also the span's raw attribute.
func semconvTextKey(list []any) []any {
// semconvRepairs copies the parts whose keys SDKs misspell into the schema's spelling. The input
// is left untouched, it is also the span's raw attribute.
func semconvRepairs(list []any) []any {
out := make([]any, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
@@ -50,13 +58,8 @@ func semconvTextKey(list []any) []any {
}
fixedParts := make([]any, 0, len(parts))
for _, p := range parts {
part, ok := p.(map[string]any)
typ := stringOf(part["type"])
if ok && (typ == "text" || typ == "reasoning") && part["content"] == nil && part["text"] != nil {
fixed := maps.Clone(part)
fixed["content"] = fixed["text"]
delete(fixed, "text")
p = fixed
if part, ok := p.(map[string]any); ok {
p = semconvRepairPart(part)
}
fixedParts = append(fixedParts, p)
}
@@ -66,3 +69,24 @@ func semconvTextKey(list []any) []any {
}
return out
}
func semconvRepairPart(part map[string]any) map[string]any {
fixed := maps.Clone(part)
switch stringOf(part["type"]) {
case "text", "reasoning":
if part["content"] == nil && part["text"] != nil {
fixed["content"] = fixed["text"]
delete(fixed, "text")
}
case "tool_call":
if args, ok := part["arguments"].(string); ok {
fixed["arguments"] = decode(args)
}
case "tool_call_response":
if _, ok := part["response"]; !ok && part["result"] != nil {
fixed["response"] = part["result"]
delete(fixed, "result")
}
}
return fixed
}

View File

@@ -157,3 +157,34 @@ func TestFormat_Semconv(t *testing.T) {
},
})
}
func TestFormat_Semconv_Repairs(t *testing.T) {
runFormatCases(t, []formatCase{
{
// pydantic-ai keeps string tool call arguments as sent, writes the tool result under result, and the
// instructions as a gen_ai.system_instructions text part:
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L3130
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/messages.py#L1724
// https://github.com/pydantic/pydantic-ai/blob/9ac853bb84ffed7ba0edccb026c5b4f3228c1de7/pydantic_ai_slim/pydantic_ai/models/instrumented.py#L292
name: "PydanticAI_ArgumentsDecoded_ResultBecomesResponse_InstructionsLead",
input: `[{"role":"user","parts":[{"type":"text","content":"Weather in Paris?"}]},{"role":"assistant","parts":[{"type":"tool_call","id":"call_stub1","name":"get_weather","arguments":"{\"city\": \"Paris\"}"}],"finish_reason":"tool_call"},{"role":"user","parts":[{"type":"tool_call_response","id":"call_stub1","name":"get_weather","result":{"city":"Paris","temp_c":18}}]}]`,
output: `[{"role":"assistant","parts":[{"type":"text","content":"Paris is clear at 18C."}],"finish_reason":"stop"}]`,
attributes: map[string]any{GenAISystemInstructions: `[{"type":"text","content":"Use the tool."}]`},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Use the tool."}]},
{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_stub1", "arguments": {"city": "Paris"}}]},
{"role": "user", "parts": [{"type": "tool_call_response", "id": "call_stub1", "response": {"city": "Paris", "temp_c": 18}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Paris is clear at 18C."}], "finish_reason": "stop"}]`,
},
{
// litellm copies the choice finish_reason into the semconv message as OpenAI spells it:
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2822
name: "LiteLLMFinishReasonSpelling_Normalised",
output: `[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]`,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -112,6 +112,16 @@ So, the ball costs 5 cents.`,
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"city\":\"Paris\",\"temp_c\":21}"}]}]`,
wantOutput: `[]`,
},
{
// openinference capture, CreateEmbeddings span: the mapper moved the request and the vectors onto
// the message attributes and copied the span kind onto gen_ai.operation.name
name: "EmbeddingsOperation_EmptyLists",
input: `{"input": ["observability is the new monitoring"], "model": "text-embedding-3-small"}`,
output: `{"data":[{"embedding":[-0.0039,0.0203],"index":0}],"model":"text-embedding-3-small"}`,
attributes: map[string]any{GenAIOperationName: "EMBEDDING", GenAIRequestModel: "text-embedding-3-small"},
wantInput: `[]`,
wantOutput: `[]`,
},
{
name: "NoMessageAttributes_EmptyLists",
attributes: map[string]any{GenAIRequestModel: "gpt-4o", GenAIToolName: "get_weather"},
@@ -120,3 +130,108 @@ So, the ball costs 5 cents.`,
},
})
}
func TestIndexed(t *testing.T) {
testCases := []struct {
name string
doc map[string]any
want []map[string]any
}{
{
name: "NumericKeys_SortedNumerically",
doc: document(map[string]any{"llm.input_messages.10.message.role": "user", "llm.input_messages.2.message.role": "system", "llm.input_messages.x.message.role": "skipped"}),
want: []map[string]any{{"message": map[string]any{"role": "system"}}, {"message": map[string]any{"role": "user"}}},
},
{
name: "StructuredList_KeptInOrder",
doc: map[string]any{"llm": map[string]any{"input_messages": []any{map[string]any{"message": "b"}, map[string]any{"message": "a"}, "not an object"}}},
want: []map[string]any{{"message": "b"}, {"message": "a"}},
},
{name: "Absent_Empty", doc: map[string]any{}, want: []map[string]any{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, indexed(testCase.doc, openInferenceInputMessages))
})
}
}
func TestFormat_SystemInstructions(t *testing.T) {
input := `[{"role":"user","parts":[{"type":"text","content":"hi"}]}]`
runFormatCases(t, []formatCase{
{
// the langchain instrumentation moves system messages out of the history into gen_ai.system_instructions,
// a JSON list of parts:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py#L304
name: "ListOfParts_LeadsInput",
input: input,
attributes: map[string]any{GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
// the OpenInference strands processor keeps gen_ai.system_instructions on the span and inserts the
// same prompt as message 0
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L152
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-strands-agents/src/openinference/instrumentation/strands_agents/processor.py#L293
name: "SameTextLeadsInput_Skipped",
input: `[{"role":"system","parts":[{"type":"text","content":"You are concise."}]},{"role":"user","parts":[{"type":"text","content":"hi"}]}]`,
attributes: map[string]any{GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
name: "BareString_TextPart",
input: input,
attributes: map[string]any{GenAISystemInstructions: "be brief"},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "be brief"}]},
{"role": "user", "parts": [{"type": "text", "content": "hi"}]}
]`,
wantOutput: `[]`,
},
{
// a create_agent span carries instructions alone
name: "NoMessages_Nothing",
attributes: map[string]any{GenAISystemInstructions: "be brief"},
wantInput: `[]`,
wantOutput: `[]`,
},
})
}
func TestFormat_FinishReasons(t *testing.T) {
output := `[{"role":"assistant","parts":[{"type":"text","content":"a"}],"finish_reason":"stop"},{"role":"assistant","parts":[{"type":"text","content":"b"}]}]`
runFormatCases(t, []formatCase{
{
name: "List_OverridesMessages",
output: output,
attributes: map[string]any{GenAIResponseFinishReasons: []any{"tool_calls", "length"}},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "a"}], "finish_reason": "tool_call"}, {"role": "assistant", "parts": [{"type": "text", "content": "b"}], "finish_reason": "length"}]`,
},
{
name: "LengthMismatch_LeavesMessages_Warns",
output: output,
attributes: map[string]any{GenAIResponseFinishReasons: []any{"stop"}},
warnings: []string{"gen_ai.response.finish_reasons has 1 entries for 2 output messages"},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "a"}], "finish_reason": "stop"}, {"role": "assistant", "parts": [{"type": "text", "content": "b"}]}]`,
},
{
// llm.finish_reason copied by the mapper as a bare string, in the Vercel AI SDK spelling:
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-finish-reason.ts
name: "BareString_SingleChoice",
output: `{"choices":[{"finish_reason":"stop","message":{"role":"assistant","content":"hi"}}]}`,
attributes: map[string]any{GenAIResponseFinishReasons: "tool-calls"},
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -1,12 +1,15 @@
package aiobservabilitytypes
// Format: gen_ai.tool.call.arguments and gen_ai.tool.call.result on an execute_tool span, each an
// object or its JSON string (open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/registry.yaml).
// object or its JSON string:
// https://github.com/open-telemetry/semantic-conventions-genai/blob/06ec68e722c45a7218e23ea1bc1339fe4e21ecae/model/gen-ai/registry.yaml#L545
// Written by: opentelemetry-instrumentation-langchain, traceloop @tool, OpenInference TOOL spans and
// Vercel ai.toolCall.* through the span mapper.
// Envelopes unwrapped: LangChain's callback payloads, {input_str, tags, metadata, inputs, kwargs} and
// {output, kwargs} (openllmetry callback_handler.py on_tool_start/on_tool_end), and the traceloop
// decorator's {args, kwargs} (traceloop-sdk decorators/base.py _handle_span_input).
// Envelopes unwrapped: LangChain's on_tool_start and on_tool_end payloads, {input_str, tags, metadata,
// inputs, kwargs} and {output, kwargs}:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py#L837
// The traceloop decorator's {args, kwargs}:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L169
// tool renders the call as an assistant tool_call and the result as a tool tool_call_response, both
// carrying gen_ai.tool.call.id so the pair can be matched.

View File

@@ -7,8 +7,9 @@ import "testing"
func TestFormat_Tool(t *testing.T) {
runFormatCases(t, []formatCase{
{
// langchain capture, execute_tool get_weather span; opentelemetry-instrumentation-langchain
// callback_handler.py on_tool_start/on_tool_end envelopes
// gen_ai.tool.call.arguments and gen_ai.tool.call.result written by the langchain instrumentation itself,
// wrapped in its on_tool_start and on_tool_end payloads:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py#L837
name: "LangChainEnvelopes_InputsAndOutputUnwrapped",
attributes: map[string]any{
GenAIToolName: "get_weather",
@@ -19,8 +20,9 @@ func TestFormat_Tool(t *testing.T) {
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}]`,
},
{
// openllmetry capture, get_weather.tool span; traceloop-sdk decorators/base.py
// _handle_span_input {args, kwargs} and the result encoded as a JSON string twice
// traceloop.entity.input and traceloop.entity.output from the @tool decorator, copied by the mapper;
// the tool returned a JSON string, which json.dumps encoded again:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L169
name: "TraceloopDecorator_KwargsUnwrapped_ResultDecodedTwice",
attributes: map[string]any{
GenAIToolName: "get_weather",
@@ -31,8 +33,8 @@ func TestFormat_Tool(t *testing.T) {
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}}]}]`,
},
{
// a decorated function returning None is json.dumps(None); the openllmetry capture shows it on
// workflow spans, with this positional-args input
// traceloop.entity.output of a @tool returning None, json.dumps(None), copied by the mapper:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/traceloop-sdk/traceloop/sdk/decorators/base.py#L185
name: "TraceloopDecorator_PositionalArgsOnly_ListKept",
attributes: map[string]any{
GenAIToolName: "lookup",
@@ -52,8 +54,8 @@ func TestFormat_Tool(t *testing.T) {
wantOutput: `[]`,
},
{
// openinference-openai-agents capture, get_weather TOOL span: input.value and output.value
// copied by the mapper (Arize-ai/openinference@12f2cc03, spec/traces.md)
// input.value and output.value of an OpenInference TOOL span, copied by the mapper:
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/traces.md
name: "OpenInferenceToolSpan_PlainJSONAndText",
attributes: map[string]any{
GenAIToolName: "get_weather",
@@ -64,8 +66,8 @@ func TestFormat_Tool(t *testing.T) {
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": "27C, partly cloudy, light breeze from the south."}]}]`,
},
{
// vercel capture, ai.toolCall span: ai.toolCall.{id,name,args,result} copied by the mapper
// (https://ai-sdk.dev/docs/ai-sdk-core/telemetry)
// ai.toolCall.{id,name,args,result} of a Vercel ai.toolCall span, copied by the mapper:
// https://ai-sdk.dev/docs/ai-sdk-core/telemetry
name: "VercelToolCall_IDOnBothParts",
attributes: map[string]any{
GenAIToolName: "get_current_weather",

View File

@@ -0,0 +1,87 @@
package aiobservabilitytypes
// Format: the flattened gen_ai.prompt.N.* and gen_ai.completion.N.* keys of openllmetry's instrumentations
// from before their semconv rewrite, role, content, tool_call_id, tool_calls.M.{id,name,arguments} and
// finish_reason, with list content as its JSON:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.py#L159
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L408
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L460
// Written by: crewai through the openai instrumentation 0.45.6, mistralai 0.61.0 (https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L83),
// litellm beside its semconv value.
const (
traceloopPrompts = "gen_ai.prompt"
traceloopCompletions = "gen_ai.completion"
)
func (f *formatting) traceloop() bool {
prompts := indexed(f.attributes, traceloopPrompts)
completions := indexed(f.attributes, traceloopCompletions)
if len(prompts) == 0 && len(completions) == 0 {
return false
}
f.out.Input = inputMessages(f.traceloopMessages(prompts))
f.out.Output = f.traceloopMessages(completions)
return true
}
// completionToolCalls fills an output message left without parts from the gen_ai.completion.N keys
// litellm writes beside its semconv value, whose parts never carry the tool call:
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2365
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2770
func (f *formatting) completionToolCalls() {
for i := range f.out.Output {
if len(f.out.Output[i].Parts) == 0 {
f.out.Output[i].Parts = append(f.out.Output[i].Parts, traceloopToolCalls(indexedAt(f.attributes, traceloopCompletions, i))...)
}
}
}
func (f *formatting) traceloopMessages(buckets []map[string]any) OutputMessages {
out := make(OutputMessages, 0, len(buckets))
for i, m := range buckets {
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"]), FinishReason: finishReason(stringOf(m["finish_reason"]))}
content, hasContent := m["content"]
if id := stringOf(m["tool_call_id"]); msg.Role == RoleTool && id != "" {
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: decode(content)})}
out = append(out, msg)
continue
}
if hasContent {
msg.Parts = append(msg.Parts, traceloopContent(content)...)
}
msg.Parts = append(msg.Parts, traceloopToolCalls(m)...)
if len(msg.Parts) == 0 {
f.warn("message %d has no content", i)
}
out = append(out, msg)
}
return out
}
// traceloopContent reads a content value, a string or the JSON of an OpenAI content block list.
func traceloopContent(content any) Parts {
if blocks, ok := decode(content).([]any); ok {
parts := make(Parts, 0, len(blocks))
for _, block := range blocks {
parts = append(parts, contentPart(block))
}
return parts
}
if text := stringOf(content); text != "" {
return Parts{part(textPart(text))}
}
return nil
}
// traceloopToolCalls reads tool_calls.M.{id,name,arguments} and litellm's function_call object.
func traceloopToolCalls(m map[string]any) Parts {
var parts Parts
for _, call := range indexed(m, "tool_calls") {
parts = append(parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), ID: optionalString(call["id"]), Arguments: decode(call["arguments"])}))
}
if call, ok := m["function_call"].(map[string]any); ok {
parts = append(parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), Arguments: decode(call["arguments"])}))
}
return parts
}

View File

@@ -0,0 +1,106 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the gen_ai.prompt.N.* and gen_ai.completion.N.* keys as the collector stored
// them for the SigNoz/scripts crewai, mistral and litellm captures.
func TestFormat_Traceloop(t *testing.T) {
runFormatCases(t, []formatCase{
{
// crewai capture, openai.chat span of a tool round, as the openai instrumentation 0.45.6 wrote
// prompts and completions before its semconv rewrite
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L408
name: "ToolRound_ToolCallAndToolMessages",
attributes: map[string]any{
"gen_ai.prompt.0.role": "system",
"gen_ai.prompt.0.content": "You are Weather Reporter. Call get_weather and summarise the result.",
"gen_ai.prompt.1.role": "user",
"gen_ai.prompt.1.content": "Current Task: What's the weather in Bengaluru?",
"gen_ai.prompt.2.role": "assistant",
"gen_ai.prompt.2.tool_calls.0.id": "call_bbVgWPe8AMBol7JlAhDkA6R7",
"gen_ai.prompt.2.tool_calls.0.name": "get_weather",
"gen_ai.prompt.2.tool_calls.0.arguments": `{"city":"Bengaluru"}`,
"gen_ai.prompt.3.role": "tool",
"gen_ai.prompt.3.tool_call_id": "call_bbVgWPe8AMBol7JlAhDkA6R7",
"gen_ai.prompt.3.content": `{"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}`,
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "stop",
"gen_ai.completion.0.content": "The current weather in Bengaluru is clear with a temperature of 18°C.",
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are Weather Reporter. Call get_weather and summarise the result."}]},
{"role": "user", "parts": [{"type": "text", "content": "Current Task: What's the weather in Bengaluru?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_bbVgWPe8AMBol7JlAhDkA6R7", "arguments": {"city": "Bengaluru"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_bbVgWPe8AMBol7JlAhDkA6R7", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather in Bengaluru is clear with a temperature of 18°C."}], "finish_reason": "stop"}]`,
},
{
// crewai capture, openai.chat span answering with two tool calls
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L460
name: "CompletionToolCalls_ToolCallParts",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.prompt.0.content": "What's the weather in Paris and in London?",
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "tool_calls",
"gen_ai.completion.0.tool_calls.0.id": "call_1",
"gen_ai.completion.0.tool_calls.0.name": "get_weather",
"gen_ai.completion.0.tool_calls.0.arguments": `{"city":"Paris"}`,
"gen_ai.completion.0.tool_calls.1.id": "call_2",
"gen_ai.completion.0.tool_calls.1.name": "get_weather",
"gen_ai.completion.0.tool_calls.1.arguments": `{"city":"London"}`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Paris and in London?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris"}},
{"type": "tool_call", "name": "get_weather", "id": "call_2", "arguments": {"city": "London"}}
], "finish_reason": "tool_call"}]`,
},
{
// mistral capture, mistralai.chat span: the instrumentation writes the role for every message but
// drops a content that is not a plain string
// https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L75
// https://github.com/traceloop/openllmetry/blob/4643b8826a6e6a4ce1968178d6ac9ca3ce607e2e/packages/opentelemetry-instrumentation-mistralai/opentelemetry/instrumentation/mistralai/__init__.py#L88
name: "RoleWithoutContent_EmptyMessageWarns",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.completion.0.role": "assistant",
"gen_ai.completion.0.finish_reason": "stop",
"gen_ai.completion.0.content": "Observability is the ability to understand a system's internal state by analyzing its external outputs.",
},
warnings: []string{"message 0 has no content"},
wantInput: `[{"role": "user", "parts": []}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Observability is the ability to understand a system's internal state by analyzing its external outputs."}], "finish_reason": "stop"}]`,
},
{
// a vision prompt: list content is written as its JSON, the OpenAI content block list
// https://github.com/traceloop/openllmetry/blob/af487056a35625a2cff1c17ac83c2c0d90a02bca/packages/opentelemetry-instrumentation-openai/opentelemetry/instrumentation/openai/shared/chat_wrappers.py#L431
name: "ContentBlockList_TextAndBlobParts",
attributes: map[string]any{
"gen_ai.prompt.0.role": "user",
"gen_ai.prompt.0.content": `[{"type":"text","text":"What animal is this?"},{"type":"image_url","image_url":{"url":"data:image/jpeg;base64,/9j/4AAQ"}}]`,
},
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is this?"},
{"type": "blob", "modality": "image", "mime_type": "image/jpeg", "content": "/9j/4AAQ"}
]}]`,
wantOutput: `[]`,
},
{
// litellm capture, litellm_request span: the semconv output has no part for the tool call, the
// gen_ai.completion.0.function_call keys beside it still carry it
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2365
// https://github.com/BerriAI/litellm/blob/9d72bae336ec0d14c41651adcb0d2e1149721f46/litellm/integrations/opentelemetry.py#L2770
name: "SemconvEmptyParts_ToolCallFromCompletionKeys",
attributes: map[string]any{
GenAIInputMessages: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
GenAIOutputMessages: `[{"role": "assistant", "parts": [], "finish_reason": "tool_calls"}]`,
"gen_ai.completion.0.function_call.name": "get_current_weather",
"gen_ai.completion.0.function_call.arguments": `{"city":"Bengaluru"}`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -0,0 +1,187 @@
package aiobservabilitytypes
import (
"encoding/base64"
"strings"
)
// Format: the AI SDK's legacy telemetry attributes. ai.prompt.messages on the doGenerate and doStream
// spans is the provider prompt, ai.prompt on the parent generateText and streamText spans is {system,
// prompt} or {messages}, ai.response.text and ai.response.toolCalls hold the answer:
// https://ai-sdk.dev/docs/ai-sdk-core/telemetry
// The prompt parts in the provider's v1 spelling, image with mimeType, tool-call args and tool-result
// result, or v2, file with mediaType, tool-call input and tool-result output:
// https://github.com/vercel/ai/blob/47bd16dd50283c82a5fef4c839d7f4102c45f5b9/packages/provider/src/language-model/v1/language-model-v1-prompt.ts#L120
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L93
// The span mapper moves ai.prompt.messages and one of the response keys onto gen_ai.*.messages; the other
// response key and ai.prompt are read where they are.
// Written by: the Vercel AI SDK, Mastra.
// Handled: text, reasoning, tool-call, tool-result, image and file parts as a data URL, a URL, bare base64
// or a serialised Buffer; ai.response.finishReason.
const (
vercelPrompt = "ai.prompt"
vercelOperation = "ai.operationId"
vercelResponseText = "ai.response.text"
vercelResponseCalls = "ai.response.toolCalls"
vercelResponseFinished = "ai.response.finishReason"
)
func (f *formatting) vercel() bool {
if attribute(f.attributes, vercelOperation) == nil && attribute(f.attributes, vercelPrompt) == nil && attribute(f.attributes, "ai.response") == nil {
return false
}
switch input := decode(attribute(f.attributes, GenAIInputMessages)).(type) {
case []any:
f.out.Input = inputMessages(vercelMessages(input))
case nil:
if prompt, ok := decode(attribute(f.attributes, vercelPrompt)).(map[string]any); ok {
f.out.Input = inputMessages(vercelParentPrompt(prompt))
}
default:
f.out.Input = inputMessages(OutputMessages{genericMessage(input)})
f.warn("input is not a message list, kept as generic")
}
text, calls := attribute(f.attributes, vercelResponseText), decode(attribute(f.attributes, vercelResponseCalls))
switch output := decode(attribute(f.attributes, GenAIOutputMessages)).(type) {
case string:
text = output
case []any:
calls = output
}
msg := OutputMessage{Role: RoleAssistant, Parts: Parts{}, FinishReason: finishReason(stringOf(attribute(f.attributes, vercelResponseFinished)))}
if s := stringOf(text); s != "" {
msg.Parts = append(msg.Parts, part(textPart(s)))
}
if list, ok := calls.([]any); ok {
for _, item := range list {
if call, ok := item.(map[string]any); ok {
msg.Parts = append(msg.Parts, vercelToolCall(call))
}
}
}
if len(msg.Parts) > 0 {
f.out.Output = OutputMessages{msg}
}
return true
}
// vercelParentPrompt reads the parent span's ai.prompt: the system prompt with either a bare prompt or
// the messages.
func vercelParentPrompt(prompt map[string]any) OutputMessages {
var msgs OutputMessages
if parts := systemParts(prompt["system"]); len(parts) > 0 {
msgs = append(msgs, OutputMessage{Role: RoleSystem, Parts: parts})
}
if messages, ok := prompt["messages"].([]any); ok {
return append(msgs, vercelMessages(messages)...)
}
if text := stringOf(prompt["prompt"]); text != "" {
msgs = append(msgs, OutputMessage{Role: RoleUser, Parts: Parts{part(textPart(text))}})
}
return msgs
}
func vercelMessages(list []any) OutputMessages {
out := make(OutputMessages, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]any)
if !ok {
out = append(out, genericMessage(item))
continue
}
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}}
switch content := m["content"].(type) {
case string:
if content != "" {
msg.Parts = append(msg.Parts, part(textPart(content)))
}
case []any:
for _, item := range content {
msg.Parts = append(msg.Parts, vercelPart(item))
}
}
out = append(out, msg)
}
return out
}
func vercelPart(item any) Part {
p, ok := item.(map[string]any)
if !ok {
return contentPart(item)
}
switch stringOf(p["type"]) {
case "text":
return part(textPart(stringOf(p["text"])))
case "reasoning":
return part(ReasoningPart{Type: PartTypeReasoning, Content: stringOf(p["text"])})
case "redacted-reasoning":
return part(ReasoningPart{Type: PartTypeReasoning})
case "tool-call":
return vercelToolCall(p)
case "tool-result":
response := p["result"]
if output, ok := p["output"].(map[string]any); ok {
response = output["value"]
}
return part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: optionalString(p["toolCallId"]), Response: response})
case "image":
mime := stringOf(p["mimeType"])
if mime == "" {
mime = stringOf(p["mediaType"])
}
if media, ok := vercelMedia(p["image"], mime, ModalityImage); ok {
return media
}
case "file":
mime := stringOf(p["mediaType"])
if mime == "" {
mime = stringOf(p["mimeType"])
}
if media, ok := vercelMedia(p["data"], mime, modalityOf(mime)); ok {
return media
}
}
return part(GenericPart(p))
}
// vercelToolCall reads {toolCallId, toolName, input} or the v1 {toolCallId, toolName, args}; the
// arguments may be a JSON string:
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L126
// https://github.com/vercel/ai/blob/47bd16dd50283c82a5fef4c839d7f4102c45f5b9/packages/provider/src/language-model/v1/language-model-v1-prompt.ts#L175
func vercelToolCall(call map[string]any) Part {
args := call["input"]
if args == nil {
args = call["args"]
}
return part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["toolName"]), ID: optionalString(call["toolCallId"]), Arguments: decode(args)})
}
// vercelMedia reads a data URL, a URL, bare base64 or a serialised Node Buffer {type: Buffer,
// data: [bytes]}, which is how the parent span's ai.prompt carries an image given as bytes.
func vercelMedia(value any, mimeType string, modality Modality) (Part, bool) {
switch v := value.(type) {
case string:
if v == "" {
return Part{}, false
}
if strings.Contains(v, "://") || strings.HasPrefix(v, "data:") {
return part(mediaPart(v, modality)), true
}
return part(BlobPart{Type: PartTypeBlob, Modality: modality, Content: v, MimeType: optionalString(mimeType)}), true
case map[string]any:
bytes, ok := v["data"].([]any)
if !ok {
return Part{}, false
}
raw := make([]byte, 0, len(bytes))
for _, b := range bytes {
n, _ := b.(float64)
raw = append(raw, byte(n))
}
return part(BlobPart{Type: PartTypeBlob, Modality: modality, Content: base64.StdEncoding.EncodeToString(raw), MimeType: optionalString(mimeType)}), true
}
return Part{}, false
}

View File

@@ -0,0 +1,159 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the ai.* attributes as the collector stored them for the SigNoz/scripts vercel and
// mastra captures, the AI SDK v4 shapes; the v5 cases follow the provider's v2 prompt types at the
// linked lines.
func TestFormat_Vercel(t *testing.T) {
runFormatCases(t, []formatCase{
{
// vercel capture, ai.generateText.doGenerate span after a tool round: v1 tool-call args and
// tool-result result
// https://github.com/vercel/ai/blob/47bd16dd50283c82a5fef4c839d7f4102c45f5b9/packages/provider/src/language-model/v1/language-model-v1-prompt.ts#L175
name: "V4ToolRound_ToolCallAndResultParts",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "stop",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is the weather in Paris and in London, in celsius? Use the tool for each."}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_isBPb3o3cqhY8OpugQntLOgy","toolName":"get_current_weather","args":{"city":"Paris","unit":"c"}},{"type":"tool-call","toolCallId":"call_sH9xd5EWFkiSaWmYvg4CeWTl","toolName":"get_current_weather","args":{"city":"London","unit":"c"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_isBPb3o3cqhY8OpugQntLOgy","toolName":"get_current_weather","result":{"city":"Paris","unit":"c","temperature":18,"condition":"clear"}},{"type":"tool-result","toolCallId":"call_sH9xd5EWFkiSaWmYvg4CeWTl","toolName":"get_current_weather","result":{"city":"London","unit":"c","temperature":18,"condition":"clear"}}]}]`,
GenAIOutputMessages: "The current weather is as follows:\n\n- **Paris**: 18°C, clear\n- **London**: 18°C, clear",
},
wantInput: `[
{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Paris and in London, in celsius? Use the tool for each."}]},
{"role": "assistant", "parts": [
{"type": "tool_call", "name": "get_current_weather", "id": "call_isBPb3o3cqhY8OpugQntLOgy", "arguments": {"city": "Paris", "unit": "c"}},
{"type": "tool_call", "name": "get_current_weather", "id": "call_sH9xd5EWFkiSaWmYvg4CeWTl", "arguments": {"city": "London", "unit": "c"}}
]},
{"role": "tool", "parts": [
{"type": "tool_call_response", "id": "call_isBPb3o3cqhY8OpugQntLOgy", "response": {"city": "Paris", "unit": "c", "temperature": 18, "condition": "clear"}},
{"type": "tool_call_response", "id": "call_sH9xd5EWFkiSaWmYvg4CeWTl", "response": {"city": "London", "unit": "c", "temperature": 18, "condition": "clear"}}
]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "The current weather is as follows:\n\n- **Paris**: 18°C, clear\n- **London**: 18°C, clear"}], "finish_reason": "stop"}]`,
},
{
// vercel capture, ai.streamText.doStream span: the mapper moved ai.response.toolCalls onto the output
// and left the empty ai.response.text behind
name: "V4ToolCalls_ToolCallParts_EmptyTextIgnored",
attributes: map[string]any{
"ai.operationId": "ai.streamText.doStream",
"ai.response.finishReason": "tool-calls",
"ai.response.text": "",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru in celsius?"}]}]`,
GenAIOutputMessages: `[{"type":"tool-call","toolCallId":"call_l0ycKcmt1uPCdGWR4SkwoLBv","toolName":"get_current_weather","args":{"city":"Bengaluru","unit":"c"}}]`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru in celsius?"}]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "arguments": {"city": "Bengaluru", "unit": "c"}}], "finish_reason": "tool_call"}]`,
},
{
// vercel capture, ai.generateText parent span: ai.prompt is unmapped, the answer text was moved
name: "ParentPromptWithSystem_SystemAndUserMessages",
attributes: map[string]any{
"ai.operationId": "ai.generateText",
"ai.prompt": `{"system":"You are concise.","prompt":"Say hello in five words."}`,
"ai.response.finishReason": "stop",
GenAIOutputMessages: "Hello! How are you today?",
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are concise."}]},
{"role": "user", "parts": [{"type": "text", "content": "Say hello in five words."}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hello! How are you today?"}], "finish_reason": "stop"}]`,
},
{
// vercel capture, ai.generateText parent span of a vision call: the image is a serialised Node Buffer
name: "ParentPromptMessages_BufferImage_BlobPart",
attributes: map[string]any{
"ai.operationId": "ai.generateText",
"ai.prompt": `{"messages":[{"role":"user","content":[{"type":"text","text":"What animal is in this image? One sentence."},{"type":"image","image":{"type":"Buffer","data":[137,80,78,71]},"mediaType":"image/png"}]}]}`,
GenAIOutputMessages: "A duck.",
},
wantInput: `[{"role": "user", "parts": [
{"type": "text", "content": "What animal is in this image? One sentence."},
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw=="}
]}]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "A duck."}]}]`,
},
{
// the v2 prompt after a tool round: tool-call input and tool-result output
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L126
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L161
name: "V5ToolRound_InputAndOutputSpellings",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "stop",
GenAIInputMessages: `[{"role":"system","content":"Use the tool."},{"role":"user","content":[{"type":"text","text":"Weather in Paris?"}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_1","toolName":"getWeather","input":{"city":"Paris"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_1","toolName":"getWeather","output":{"type":"json","value":{"city":"Paris","tempC":18,"summary":"Clear"}}}]}]`,
GenAIOutputMessages: "Paris is clear at 18C.",
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "Use the tool."}]},
{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris?"}]},
{"role": "assistant", "parts": [{"type": "tool_call", "name": "getWeather", "id": "call_1", "arguments": {"city": "Paris"}}]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": {"city": "Paris", "tempC": 18, "summary": "Clear"}}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Paris is clear at 18C."}], "finish_reason": "stop"}]`,
},
{
// ai.response.toolCalls in the v2 spelling, the arguments under input; the text the mapper left
// behind in ai.response.text comes first
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L126
name: "V5ToolCallsBesideText_TextThenToolCall",
attributes: map[string]any{
"ai.operationId": "ai.streamText.doStream",
"ai.response.finishReason": "tool-calls",
"ai.response.text": "Let me check.",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"Weather in Paris? Use the tool."}]}]`,
GenAIOutputMessages: `[{"toolCallId":"call_2","toolName":"getWeather","input":{"city":"Paris"}}]`,
},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Weather in Paris? Use the tool."}]}]`,
wantOutput: `[{"role": "assistant", "parts": [
{"type": "text", "content": "Let me check."},
{"type": "tool_call", "name": "getWeather", "id": "call_2", "arguments": {"city": "Paris"}}
], "finish_reason": "tool_call"}]`,
},
{
// the v2 file part, data as bare base64 or a URL with its mediaType, a reasoning part, and a tool
// result given as text
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L93
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/provider/src/language-model/v2/language-model-v2-prompt.ts#L187
name: "V5FileParts_BlobsByMediaType_URLAsUri",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
GenAIInputMessages: `[{"role":"user","content":[{"type":"text","text":"What is in these?"},{"type":"file","mediaType":"image/png","data":"iVBORw0KGgo="},{"type":"file","mediaType":"application/pdf","filename":"a.pdf","data":"JVBERi0xLjQgc3R1Yg=="},{"type":"file","mediaType":"audio/wav","data":"https://example.com/a.wav"},{"type":"reasoning","text":"thinking"}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_1","toolName":"read","output":{"type":"text","value":"done"}}]}]`,
},
wantInput: `[
{"role": "user", "parts": [
{"type": "text", "content": "What is in these?"},
{"type": "blob", "modality": "image", "mime_type": "image/png", "content": "iVBORw0KGgo="},
{"type": "blob", "modality": "document", "mime_type": "application/pdf", "content": "JVBERi0xLjQgc3R1Yg=="},
{"type": "uri", "modality": "audio", "uri": "https://example.com/a.wav"},
{"type": "reasoning", "content": "thinking"}
]},
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "done"}]}
]`,
wantOutput: `[]`,
},
{
// mastra capture, ai.generateText.doGenerate span: the v4 shapes with the arguments as a JSON string
name: "MastraToolCalls_ToolCallPart",
attributes: map[string]any{
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "tool-calls",
GenAIInputMessages: `[{"role":"system","content":"You are a weather assistant. When asked about weather, call get-weather once per city and summarise."},{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru?"}]}]`,
GenAIOutputMessages: `[{"toolCallType":"function","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":"{\"city\":\"Bengaluru\"}"}]`,
},
wantInput: `[
{"role": "system", "parts": [{"type": "text", "content": "You are a weather assistant. When asked about weather, call get-weather once per city and summarise."}]},
{"role": "user", "parts": [{"type": "text", "content": "What is the weather in Bengaluru?"}]}
]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "getWeather", "id": "call_526phoykytobsvLouzES805J", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]`,
},
{
// vercel capture, ai.generateText span of a failed call
name: "PromptOnlyNoResponse_InputOnly",
attributes: map[string]any{"ai.operationId": "ai.generateText", "ai.prompt": `{"prompt":"this should fail"}`},
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "this should fail"}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -21,17 +21,25 @@ def test_thread_returns_gen_ai_spans_in_order(
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
root_id, first_llm_id, tool_id, agent_id, second_llm_id, third_llm_id = (TraceIdGenerator.span_id() for _ in range(6))
root_id, first_llm_id, tool_id, agent_id, second_llm_id, legacy_llm_id, third_llm_id, flattened_llm_id, responses_llm_id, langchain_llm_id, openrouter_llm_id, vercel_llm_id, anthropic_llm_id, gemini_llm_id = (TraceIdGenerator.span_id() for _ in range(14))
resources = {"service.name": "tracedetail-thread"}
first_input = json.dumps([{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}])
first_output = json.dumps([{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": {"city": "Bangalore"}}], "finish_reason": "tool_call"}])
first_output = json.dumps([{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_1", "name": "get_weather", "arguments": {"city": "Bangalore"}}]}])
first_instructions = json.dumps([{"type": "text", "content": "Answer with the tool."}])
second_input = json.dumps([{"role": "tool", "content": "sunny", "tool_call_id": "call_1"}])
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=9), trace_id=trace_id, span_id=root_id, name="POST /chat", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attribute_write_mode="json_only"),
Traces(
timestamp=now - timedelta(seconds=8), trace_id=trace_id, span_id=first_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": first_input, "gen_ai.output.messages": first_output}, attribute_write_mode="json_only"
timestamp=now - timedelta(seconds=8),
trace_id=trace_id,
span_id=first_llm_id,
parent_span_id=root_id,
name="chat gpt-4o",
resources=resources,
attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.system_instructions": first_instructions, "gen_ai.input.messages": first_input, "gen_ai.output.messages": first_output, "gen_ai.response.finish_reasons": json.dumps(["tool_calls"])},
attribute_write_mode="json_only",
),
Traces(
timestamp=now - timedelta(seconds=6),
@@ -45,7 +53,122 @@ def test_thread_returns_gen_ai_spans_in_order(
),
Traces(timestamp=now - timedelta(seconds=5), trace_id=trace_id, span_id=agent_id, parent_span_id=root_id, name="invoke_agent planner", resources=resources, attributes={"gen_ai.agent.name": "planner"}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=4), trace_id=trace_id, span_id=second_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": second_input}, attribute_write_mode="json_only"),
Traces(
timestamp=now - timedelta(seconds=3),
trace_id=trace_id,
span_id=legacy_llm_id,
parent_span_id=root_id,
name="openai.chat",
resources=resources,
attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.prompt.0.role": "user", "gen_ai.prompt.0.content": "and tomorrow?", "gen_ai.completion.0.role": "assistant", "gen_ai.completion.0.content": "Also sunny.", "gen_ai.completion.0.finish_reason": "stop"},
attribute_write_mode="json_only",
),
Traces(timestamp=now - timedelta(seconds=2), trace_id=trace_id, span_id=third_llm_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": "It is sunny in Bangalore."}, attribute_write_mode="json_only"),
Traces(
timestamp=now - timedelta(seconds=1),
trace_id=trace_id,
span_id=flattened_llm_id,
parent_span_id=root_id,
name="ChatCompletion",
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o",
"llm.input_messages.0.message.role": "user",
"llm.input_messages.0.message.content": "thanks",
"llm.output_messages.0.message.role": "assistant",
"llm.output_messages.0.message.content": "You're welcome.",
"llm.finish_reason": "stop",
},
attribute_write_mode="json_only",
),
Traces(
timestamp=now,
trace_id=trace_id,
span_id=responses_llm_id,
parent_span_id=root_id,
name="responses gpt-4o",
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o",
"gen_ai.input.messages": json.dumps({"model": "gpt-4o", "instructions": "Be brief.", "input": [{"role": "user", "content": "bye"}]}),
"gen_ai.output.messages": json.dumps({"object": "response", "status": "completed", "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Bye!"}]}]}),
},
attribute_write_mode="json_only",
),
Traces(
timestamp=now + timedelta(seconds=1),
trace_id=trace_id,
span_id=langchain_llm_id,
parent_span_id=root_id,
name="ChatOpenAI",
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o",
"gen_ai.input.messages": json.dumps({"messages": [[{"lc": 1, "type": "constructor", "id": ["langchain", "schema", "messages", "HumanMessage"], "kwargs": {"content": "see you", "type": "human"}}]]}),
"gen_ai.output.messages": json.dumps(
{"generations": [[{"text": "See you!", "generation_info": {"finish_reason": "stop"}, "type": "ChatGeneration", "message": {"lc": 1, "type": "constructor", "id": ["langchain", "schema", "messages", "AIMessage"], "kwargs": {"content": "See you!", "type": "ai"}}}]]}
),
},
attribute_write_mode="json_only",
),
Traces(
timestamp=now + timedelta(seconds=2),
trace_id=trace_id,
span_id=openrouter_llm_id,
parent_span_id=root_id,
name="LLM Generation",
resources=resources,
attributes={
"gen_ai.request.model": "openai/gpt-4o-mini",
"gen_ai.input.messages": json.dumps({"messages": [{"role": "user", "content": "one word"}]}),
"gen_ai.output.messages": json.dumps({"completion": "Yes.", "reasoning": None, "rawRequest": {"model": "openai/gpt-4o-mini"}}),
},
attribute_write_mode="json_only",
),
Traces(
timestamp=now + timedelta(seconds=3),
trace_id=trace_id,
span_id=vercel_llm_id,
parent_span_id=root_id,
name="ai.generateText.doGenerate",
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o-mini",
"ai.operationId": "ai.generateText.doGenerate",
"ai.response.finishReason": "stop",
"gen_ai.input.messages": json.dumps([{"role": "user", "content": [{"type": "text", "text": "last one"}]}]),
"gen_ai.output.messages": "Done.",
},
attribute_write_mode="json_only",
),
Traces(
timestamp=now + timedelta(seconds=4),
trace_id=trace_id,
span_id=anthropic_llm_id,
parent_span_id=root_id,
name="messages claude-sonnet-4-5",
resources=resources,
attributes={
"gen_ai.request.model": "claude-sonnet-4-5",
"gen_ai.input.messages": json.dumps({"system": "Be terse.", "messages": [{"role": "user", "content": "ping"}]}),
"gen_ai.output.messages": json.dumps({"type": "message", "role": "assistant", "content": [{"type": "text", "text": "pong"}], "stop_reason": "end_turn"}),
},
attribute_write_mode="json_only",
),
Traces(
timestamp=now + timedelta(seconds=5),
trace_id=trace_id,
span_id=gemini_llm_id,
parent_span_id=root_id,
name="generateContent gemini-2.5-flash",
resources=resources,
attributes={
"gen_ai.request.model": "gemini-2.5-flash",
"gen_ai.input.messages": json.dumps({"contents": [{"role": "user", "parts": [{"text": "ping"}]}]}),
"gen_ai.output.messages": json.dumps({"candidates": [{"content": {"role": "model", "parts": [{"text": "pong"}]}, "finishReason": "STOP"}]}),
},
attribute_write_mode="json_only",
),
]
)
@@ -54,10 +177,10 @@ def test_thread_returns_gen_ai_spans_in_order(
assert response.status_code == HTTPStatus.OK, response.text
thread = response.json()["data"]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, tool_id, agent_id, second_llm_id, third_llm_id]
assert [span["span_id"] for span in thread["spans"]] == [first_llm_id, tool_id, agent_id, second_llm_id, legacy_llm_id, third_llm_id, flattened_llm_id, responses_llm_id, langchain_llm_id, openrouter_llm_id, vercel_llm_id, anthropic_llm_id, gemini_llm_id]
assert "nextCursor" not in thread
first, tool, agent, input_only, output_only = thread["spans"]
first, tool, agent, input_only, legacy, output_only, flattened, responses, langchain, openrouter, vercel, anthropic, gemini = thread["spans"]
assert agent["attributes"]["gen_ai.agent.name"] == "planner"
assert agent["formattedInput"] == []
assert agent["formattedOutput"] == []
@@ -73,7 +196,7 @@ def test_thread_returns_gen_ai_spans_in_order(
assert output_only["attributes"]["gen_ai.output.messages"] == "It is sunny in Bangalore."
tool_call = {"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Bangalore"}}
assert first["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
assert first["formattedInput"] == [{"role": "system", "parts": [{"type": "text", "content": "Answer with the tool."}]}, {"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
assert first["formattedOutput"] == [{"role": "assistant", "parts": [tool_call], "finish_reason": "tool_call"}]
assert first["formatterWarnings"] == []
assert tool["formattedInput"] == [{"role": "assistant", "parts": [tool_call]}]
@@ -83,6 +206,24 @@ def test_thread_returns_gen_ai_spans_in_order(
assert output_only["formattedInput"] == []
assert output_only["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "It is sunny in Bangalore."}]}]
assert output_only["formatterWarnings"] == ["bare assistant text, role assumed"]
assert legacy["attributes"]["gen_ai.prompt.0.content"] == "and tomorrow?"
assert legacy["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "and tomorrow?"}]}]
assert legacy["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "Also sunny."}], "finish_reason": "stop"}]
assert flattened["attributes"]["llm.input_messages.0.message.content"] == "thanks"
assert flattened["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "thanks"}]}]
assert flattened["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "You're welcome."}], "finish_reason": "stop"}]
assert responses["formattedInput"] == [{"role": "system", "parts": [{"type": "text", "content": "Be brief."}]}, {"role": "user", "parts": [{"type": "text", "content": "bye"}]}]
assert responses["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "Bye!"}], "finish_reason": "stop"}]
assert langchain["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "see you"}]}]
assert langchain["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "See you!"}], "finish_reason": "stop"}]
assert openrouter["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "one word"}]}]
assert openrouter["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "Yes."}]}]
assert vercel["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "last one"}]}]
assert vercel["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "Done."}], "finish_reason": "stop"}]
assert anthropic["formattedInput"] == [{"role": "system", "parts": [{"type": "text", "content": "Be terse."}]}, {"role": "user", "parts": [{"type": "text", "content": "ping"}]}]
assert anthropic["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "pong"}], "finish_reason": "stop"}]
assert gemini["formattedInput"] == [{"role": "user", "parts": [{"type": "text", "content": "ping"}]}]
assert gemini["formattedOutput"] == [{"role": "assistant", "parts": [{"type": "text", "content": "pong"}], "finish_reason": "stop"}]
def test_thread_paginates_with_cursors(