Compare commits

..

20 Commits

Author SHA1 Message Date
nityanandagohain
82b3c23ebf fix(tracedetail): read gen_ai messages from flat attribute keys
A key that is both a value and a parent keeps the object, so a span
renders the same whatever the map order.

Assisted-by: Claude Opus 5.5
2026-10-16 13:08:49 +05:30
Nityananda Gohain
3ea1c04137 Merge branch 'main' into ns/read-attributes-native-json 2026-10-16 13:02:48 +05:30
Gaurav Tewari
c1cc58ac50 feat: use allow list in getSpanDisplayData (#13124)
<!--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

- Span details (pretty and JSON views) now shows an allow list of
queryable span fields instead of hiding a deny list.
- Hides rendering-only keys: `time_unix`, `level`, `has_children`,
`sub_tree_node_count`, `service.name`, and the
`formattedInput`/`formattedOutput`/`formatter`/`formatterWarnings`
fields from #13121.
- `attributes` and `resource` are shown in full.

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

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings
before - 
<img width="1637" height="1092" alt="image"
src="https://github.com/user-attachments/assets/fd7a3af8-c558-49d2-b120-ffbddf24924a"
/>
now - 
<img width="1683" height="1094" alt="image"
src="https://github.com/user-attachments/assets/01d18ac6-4b75-4d42-9c16-60d6f1911723"
/>


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

https://github.com/SigNoz/nerve-pod/issues/432#issuecomment-6164009733

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-16 06:39:46 +00:00
Pandey
c2dd94f190 fix(alerts): drop vulnerable v1 rule history endpoints (#12163)
#### Description

- Remove `POST
/api/v1/rules/{id}/history/{stats,timeline,top_contributors,overall_status}`.
They built ClickHouse queries from the path `{id}` and body fields via
`fmt.Sprintf`, allowing SQL injection by any authenticated user.
- Superseded by the parameterized `/api/v2/rules/{id}/history/*`
handlers; the frontend already moved to v2 in #12132.
- Drop the now-unused v1 rule state history reader methods and model
types, including `GetLastSavedRuleStateHistory`, another injection sink.

#### Issues closed by this PR

Closes #11747

#### Additional Information

Breaking: v1 rule history endpoints are gone. Frontend builds predating
#12132 still call them, so the alert history page breaks until they are
upgraded.
2026-10-16 06:00:15 +00:00
Nityananda Gohain
60bc6ac96f feat(tracedetail): format raw Anthropic and Gemini bodies, skip embeddings (#13141)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
<!--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
Nikhil Soni
0eb83e03f2 fix(spantypes): drop null JSON paths from thread and list attributes
ClickHouse materializes a row's absent paths as null from its data part's path schema, so
ValuesByPath returns them. Drop them, as the old FlattenInto did, so the attributes bag omits
absent keys and the legacy fallback still triggers for pre-rollout spans.

Assisted-by: Claude Opus 4.8
2026-10-15 20:57:54 +05:30
Nikhil Soni
9dde83f263 refactor(aiobservabilitytypes): read thread attributes by full path
Per the attribute design, the formatter reads full dotted paths from the flat JSON
document and normalizes the driver's Variant wrappers at the read, instead of walking
a nested document. Fixes tool names and string-valued arguments read natively.

Assisted-by: Claude Opus 4.8
2026-10-15 20:11:13 +05:30
Nikhil Soni
455c729382 refactor(aiobservabilitytypes): normalize message inputs in the formatter
The formatter now json-normalizes structured inputs, so callers hand it the
driver's native JSON Variants directly instead of pre-flattening per consumer.

Assisted-by: Claude Opus 4.8
2026-10-15 20:08:59 +05:30
Nikhil Soni
9f27671c00 refactor(spantypes): read thread messages from the flat attributes bag
Drop rawAttribute's nested-map walk. The JSON column decodes to driver Variants,
so threadAttributes json-normalizes them to plain Go types the formatter can assert.

Assisted-by: Claude Opus 4.8
2026-10-15 20:08:59 +05:30
Nikhil Soni
39cc63dfea test(integration): assert span attributes return flat, not nested
Pins that the native read yields flat dotted keys (db is not a nested key),
so no Go-side flattening is needed after dropping flattenJSONPaths.

Assisted-by: Claude Opus 4.8
2026-10-15 20:08:59 +05:30
Nikhil Soni
a6eefd6cb4 refactor(querier): reuse the already-flat attributes bag directly
The attributes JSON column decodes to flat dotted keys (ValuesByPath), so
mergeSpanAttributeColumns merges the legacy maps straight into that map
instead of flattening via the old flattenJSONPaths util. Drops the util and
the extra copy; test inputs are flattened to match the real shape.

Assisted-by: Claude Opus 4.8
2026-10-15 20:08:59 +05:30
Nikhil Soni
387d1090bc refactor(spantypes): read trace attributes as native chcol.JSON
StorableSpan.AttributesJSON moves from the removed JSONValue to chcol.JSON.
threadAttributes flattens via ValuesByPath (keeping a scalar-and-object key
as two dotted keys); rawAttribute walks the nested document decoded from
MarshalJSON so FormatMessages still sees raw structured values.

Assisted-by: Claude Opus 4.8
2026-10-15 20:08:59 +05:30
Nikhil Soni
390e22063a refactor(telemetrystore): read JSON columns natively via chcol.JSON
Drop the read-as-string setting and the WrapRows/JSONValue machinery; read
JSON columns as the driver's native chcol.JSON. The span attributes bag
decodes flat (ValuesByPath) so a key stored as both a scalar and an object
keeps both dotted keys; other JSON columns decode nested (NestedMap). Adds
integration tests for both the flat (traces) and nested (logs body) cases.

Assisted-by: Claude Opus 4.8
2026-10-15 20:06:54 +05:30
58 changed files with 3225 additions and 2358 deletions

View File

@@ -3702,13 +3702,6 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4451,7 +4444,6 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4467,7 +4459,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4481,7 +4472,6 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4567,18 +4557,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4658,16 +4636,6 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4856,104 +4824,6 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
color:
type: string
label:
type: string
unit:
type: string
value:
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -5067,11 +4937,6 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text
@@ -16199,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

@@ -0,0 +1,29 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getSpanDisplayData } from '../utils';
describe('getSpanDisplayData', () => {
it('keeps only allow-listed fields', () => {
const span = {
span_id: 's1',
trace_id: 't1',
name: 'GET /api',
attributes: { 'http.method': 'GET' },
resource: { 'service.name': 'api' },
level: 1,
has_children: true,
sub_tree_node_count: 3,
'service.name': 'api',
time_unix: 1700000000,
formattedInput: '[]',
} as unknown as SpanV3;
expect(getSpanDisplayData(span)).toStrictEqual({
span_id: 's1',
trace_id: 't1',
name: 'GET /api',
attributes: { 'http.method': 'GET' },
resource: { 'service.name': 'api' },
});
});
});

View File

@@ -1,3 +1,5 @@
import type { SpantypesWaterfallSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { pick } from 'lodash-es';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { COLOR_BY_OPTIONS } from './constants';
@@ -46,28 +48,43 @@ export function hasInfraMetadata(span: SpanV3 | undefined): boolean {
return INFRA_METADATA_KEYS.some((key) => getSpanAttribute(span, key));
}
// Top-level fields that exist on the API response only to support waterfall
// rendering. They have no value in the Span Details DataViewer. Drop the whole
// constant + helper once the backend stops emitting them.
const HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW: ReadonlySet<string> = new Set([
'sub_tree_node_count',
'has_children',
'level',
'service.name',
]);
// Top-level fields shown in the Span Details DataViewer. Everything else on the
// API response (e.g. waterfall rendering data) is hidden.
const VISIBLE_SPAN_FIELDS_IN_DETAILS_VIEW = [
'span_id',
'trace_id',
'parent_span_id',
'name',
'kind_string',
'duration_nano',
'has_error',
'status_code',
'status_code_string',
'status_message',
'flags',
'is_remote',
'trace_state',
'attributes',
'resource',
'events',
'references',
'http_method',
'http_url',
'http_host',
'db_name',
'db_operation',
'external_http_method',
'external_http_url',
'response_status_code',
// Keys must exist on the waterfall DTO.
] as const satisfies readonly (keyof SpantypesWaterfallSpanDTO)[];
/**
* Shallow-copies the span with waterfall-only fields stripped, for display in
* the Span Details DataViewer.
* Shallow-copies the allow-listed span fields, for display in the Span Details
* DataViewer.
*/
export function getSpanDisplayData(span: SpanV3): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(span)) {
if (!HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW.has(key)) {
result[key] = value;
}
}
return result;
return pick(span, VISIBLE_SPAN_FIELDS_IN_DETAILS_VIEW);
}
/**

2
go.mod
View File

@@ -12,7 +12,6 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -112,6 +111,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect

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,7 +17,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -40,11 +39,18 @@ func stripKeyAlias(name string) string {
return keyAliasRe.ReplaceAllString(name, "")
}
// unwrapVariant returns the concrete value inside the chcol.Variant envelope the driver scans a
// Dynamic column — a JSON path such as body_v2.level — into.
func unwrapVariant(val any) any {
if v, ok := val.(chcol.Variant); ok {
// unwrapVariant decodes a scan envelope: a chcol.Variant to its value, a chcol.JSON column to a map.
// The attributes bag decodes flat so its dotted keys merge with the legacy attribute maps and a
// scalar-and-object key stays two keys; every other JSON column decodes nested.
func unwrapVariant(name string, val any) any {
switch v := val.(type) {
case chcol.Variant:
return v.Any()
case chcol.JSON:
if name == "attributes" {
return spantypes.FlatJSONAttributes(v)
}
return v.NestedMap()
}
return val
}
@@ -54,11 +60,11 @@ func unwrapVariant(val any) any {
// series. JSON goes through encoding/json for its sorted map keys: ClickHouse groups documents by
// structure, so two rows it considers equal have to produce the same label.
func labelValue(val any) string {
val = unwrapVariant(val)
val = unwrapVariant("", val)
if val == nil {
return ""
}
if v, ok := val.(telemetrystoretypes.JSONValue); ok {
if v, ok := val.(map[string]any); ok {
if raw, err := json.Marshal(v); err == nil {
return string(raw)
}
@@ -204,7 +210,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
Value: *val,
})
case *telemetrystoretypes.JSONValue, *chcol.Variant:
case *chcol.JSON, *chcol.Variant:
val := labelValue(derefValue(ptr))
lblVals = append(lblVals, val)
lblObjs = append(lblObjs, &qbtypes.Label{
@@ -478,7 +484,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
// 2. deref each slot into the output row
row := make([]any, len(scan))
for i, cell := range scan {
row[i] = unwrapVariant(derefValue(cell))
row[i] = unwrapVariant(cd[i].Name, derefValue(cell))
}
data = append(data, row)
}
@@ -536,7 +542,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
name := stripKeyAlias(colNames[i])
// de-reference the typed pointer to any
val := unwrapVariant(reflect.ValueOf(cellPtr).Elem().Interface())
val := unwrapVariant(name, reflect.ValueOf(cellPtr).Elem().Interface())
// special-case: timestamp column
if name == "timestamp" || name == "timestamp_datetime" {
@@ -570,17 +576,18 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
// unified "attributes" and "resource" keys, and parses the stringified `events`
// and `links` columns into structured slices. Raw DB columns are removed.
//
// The `attributes` JSON column is flattened in first and the legacy maps merged over it, so maps win on collision.
// The legacy attribute maps are merged over the JSON attributes bag, so maps win on collision.
func mergeSpanAttributeColumns(data map[string]any) {
attrStr, hasStr := data["attributes_string"]
attrNum, hasNum := data["attributes_number"]
attrBool, hasBool := data["attributes_bool"]
attrJSON, _ := data["attributes"].(telemetrystoretypes.JSONValue)
attributes, _ := data["attributes"].(map[string]any)
// todo(nitya): move to resource json
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
attributes := make(map[string]any)
attrJSON.FlattenInto("", attributes)
if hasStr || hasNum || hasBool || attributes != nil || hasRes {
if attributes == nil {
attributes = map[string]any{}
}
if m, ok := attrStr.(map[string]string); ok {
for k, v := range m {
attributes[k] = v

View File

@@ -3,14 +3,9 @@ package querier
import (
"reflect"
"testing"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/telemetrystore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -83,101 +78,52 @@ func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
}
}
// A ClickHouse query can put a JSON column in the result of any request type — e.g.
// `select * from signoz_logs.logs_v2` on a body_v2 stack, where `*` covers body_v2.
func TestConsume_JSONColumn(t *testing.T) {
ts := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
body := `{"level":"error","attrs":{"code":500}}`
wantBody := telemetrystoretypes.JSONValue{
"level": "error",
"attrs": map[string]any{"code": float64(500)},
func TestUnwrapVariant(t *testing.T) {
j := chcol.NewJSON()
j.SetValueAtPath("level", "error")
j.SetValueAtPath("attrs.code", int64(500))
jNull := chcol.NewJSON()
jNull.SetValueAtPath("level", "error")
jNull.SetValueAtPath("absent", chcol.Variant{})
testCases := []struct {
name string
column string
input any
want any
}{
{name: "VariantScalar", column: "", input: chcol.NewDynamicWithType("error", "String"), want: "error"},
{name: "EmptyDynamic", column: "", input: chcol.Dynamic{}, want: nil},
{name: "PlainValuePassthrough", column: "", input: uint64(3), want: uint64(3)},
{name: "JSONColumnNested", column: "body_v2", input: *j, want: map[string]any{"level": "error", "attrs": map[string]any{"code": int64(500)}}},
{name: "AttributesColumnFlat", column: "attributes", input: *j, want: map[string]any{"level": "error", "attrs.code": int64(500)}},
{name: "AttributesColumnDropsNullPaths", column: "attributes", input: *jNull, want: map[string]any{"level": "error"}},
}
// the scalar reader reuses its scan slots across rows, so each row must still carry its own body
t.Run("scalar", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{{body, uint64(3)}, {`{"level":"warn"}`, uint64(1)}}))
payload, err := consume(rows, qbtypes.RequestTypeScalar, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.ScalarData)
require.Len(t, data.Data, 2)
assert.Equal(t, wantBody, data.Data[0][0])
assert.Equal(t, uint64(3), data.Data[0][1])
assert.Equal(t, telemetrystoretypes.JSONValue{"level": "warn"}, data.Data[1][0])
assert.Equal(t, uint64(1), data.Data[1][1])
})
t.Run("time series", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{{ts, body, uint64(3)}}))
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.TimeSeriesData)
require.Len(t, data.Aggregations, 1)
require.Len(t, data.Aggregations[0].Series, 1)
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
assert.Equal(t, float64(3), data.Aggregations[0].Series[0].Values[0].Value)
})
// grouping by a JSON column is legal in ClickHouse, so each document has to label its own
// series rather than being dropped, which would merge every group into one
t.Run("time series grouped by the JSON column", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "ts", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
{Name: "__result_0", Type: "UInt64"},
}, [][]any{
{ts, `{"level":"error"}`, uint64(7)},
{ts, `{"level":"warn"}`, uint64(2)},
}))
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.TimeSeriesData)
require.Len(t, data.Aggregations, 1)
require.Len(t, data.Aggregations[0].Series, 2)
got := map[string]float64{}
for _, series := range data.Aggregations[0].Series {
require.Len(t, series.Labels, 1)
require.Len(t, series.Values, 1)
got[series.Labels[0].Value.(string)] = series.Values[0].Value
}
assert.Equal(t, map[string]float64{`{"level":"error"}`: 7, `{"level":"warn"}`: 2}, got)
})
t.Run("raw", func(t *testing.T) {
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
{Name: "timestamp", Type: "DateTime"},
{Name: "body_v2", Type: "JSON"},
}, [][]any{{ts, body}}))
payload, err := consume(rows, qbtypes.RequestTypeRaw, nil, qbtypes.Step{}, "A")
require.NoError(t, err)
data := payload.(*qbtypes.RawData)
require.Len(t, data.Rows, 1)
assert.Equal(t, ts, data.Rows[0].Timestamp.UTC())
assert.Equal(t, wantBody, data.Rows[0].Data["body_v2"])
})
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, unwrapVariant(testCase.column, testCase.input))
})
}
}
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans
// into a chcol.Variant envelope rather than the value itself.
func TestUnwrapVariant(t *testing.T) {
assert.Equal(t, "error", unwrapVariant(chcol.NewDynamicWithType("error", "String")))
assert.Nil(t, unwrapVariant(chcol.Dynamic{}))
assert.Equal(t, uint64(3), unwrapVariant(uint64(3)))
func TestLabelValue(t *testing.T) {
testCases := []struct {
name string
input any
want string
}{
{name: "Nil", input: nil, want: ""},
{name: "Variant", input: chcol.NewDynamicWithType("error", "String"), want: "error"},
{name: "JSONMap", input: map[string]any{"level": "error", "attrs": map[string]any{"code": 500}}, want: `{"attrs":{"code":500},"level":"error"}`},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, labelValue(testCase.input))
})
}
}
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
@@ -205,12 +151,9 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
want map[string]any
}{
{
name: "JSONOnly_FlattensNestedPaths_PreservesTypes",
name: "JSONOnly_FlatPathsPreserveTypes",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{
"http": map[string]any{"route": "/api/pay", "retry": map[string]any{"count": float64(3)}},
"cache.hit": true,
},
"attributes": map[string]any{"http.route": "/api/pay", "http.retry.count": float64(3), "cache.hit": true},
},
want: map[string]any{"http.route": "/api/pay", "http.retry.count": float64(3), "cache.hit": true},
},
@@ -219,17 +162,24 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
data: map[string]any{
"attributes_string": map[string]string{"http.route": "/old", "only.map": "m"},
"attributes_number": map[string]float64{"http.status": 500},
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/new"}, "only.json": "j"},
"attributes": map[string]any{"http.route": "/new", "only.json": "j"},
},
want: map[string]any{"http.route": "/old", "only.map": "m", "http.status": float64(500), "only.json": "j"},
},
{
name: "ScalarAndObjectKey_BothSurvive",
data: map[string]any{
"attributes": map[string]any{"db.function": "refresh", "db.function.arg": float64(2)},
},
want: map[string]any{"db.function": "refresh", "db.function.arg": float64(2)},
},
{
name: "MapOnly_EmptyJSONDoc_KeepsMapValues",
data: map[string]any{
"attributes_string": map[string]string{"http.route": "/map"},
"attributes_number": map[string]float64{"http.status": 200},
"attributes_bool": map[string]bool{"cache.hit": true},
"attributes": telemetrystoretypes.JSONValue{},
"attributes": map[string]any{},
},
want: map[string]any{"http.route": "/map", "http.status": float64(200), "cache.hit": true},
},
@@ -237,60 +187,53 @@ func TestMergeSpanAttributeColumns_JSONColumn(t *testing.T) {
name: "MapOnly_NilJSON_BehavesAsAbsent",
data: map[string]any{
"attributes_string": map[string]string{"http.route": "/map"},
"attributes": telemetrystoretypes.JSONValue(nil),
"attributes": map[string]any(nil),
},
want: map[string]any{"http.route": "/map"},
},
{
name: "Arrays_StayLeafValues",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"tags": []any{"a", "b"}, "codes": []any{float64(1), float64(2)}}},
"attributes": map[string]any{"http.tags": []any{"a", "b"}, "http.codes": []any{float64(1), float64(2)}},
},
want: map[string]any{"http.tags": []any{"a", "b"}, "http.codes": []any{float64(1), float64(2)}},
},
{
name: "TopLevelArrayOfMaps_StaysNativeLeaf",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
"attributes": map[string]any{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
},
want: map[string]any{"key": []any{map[string]any{"a": float64(1)}, map[string]any{"b": float64(2)}}},
},
{
name: "NestedArrayOfMaps_StaysNativeLeaf_NoIndexPaths",
name: "ArrayOfMapsAtDottedPath_StaysNativeLeaf",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
"attributes": map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
},
want: map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
},
{
name: "DualWritten_NestedArray_IndexKeysAndJSONArrayCoexist",
name: "DualWritten_IndexKeysAndJSONArrayCoexist",
data: map[string]any{
"attributes_number": map[string]float64{"http.items.0.a": 1},
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"items": []any{map[string]any{"a": float64(1)}}}},
"attributes": map[string]any{"http.items": []any{map[string]any{"a": float64(1)}}},
},
want: map[string]any{"http.items.0.a": float64(1), "http.items": []any{map[string]any{"a": float64(1)}}},
},
{
name: "JSONNull_KeptAsNil",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"k": nil},
"attributes": map[string]any{"k": nil},
},
want: map[string]any{"k": nil},
},
{
name: "KeyIsLeafValue_NotFlattened",
name: "ScalarValueStaysScalar",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": "plaintext"},
"attributes": map[string]any{"http": "plaintext"},
},
want: map[string]any{"http": "plaintext"},
},
{
name: "KeyIsParent_FlattensToDottedPath",
data: map[string]any{
"attributes": telemetrystoretypes.JSONValue{"http": map[string]any{"route": "/a"}},
},
want: map[string]any{"http.route": "/a"},
},
}
for _, testCase := range testCases {

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -1202,7 +1201,7 @@ func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, res
// carried one. Anything that is not a decoded document — the legacy string body, a NULL cell —
// is legal under these names and left alone.
func stripEmptyBodyMessage(val any) {
bodyMap, ok := val.(telemetrystoretypes.JSONValue)
bodyMap, ok := val.(map[string]any)
if !ok {
return
}

View File

@@ -62,8 +62,6 @@ const (
primaryNamespace = "clickhouse"
archiveNamespace = "clickhouse-archive"
signozTraceDBName = "signoz_traces"
signozHistoryDBName = "signoz_analytics"
ruleStateHistoryTableName = "distributed_rule_state_history_v0"
signozDurationMVTable = "distributed_durationSort"
signozUsageExplorerTable = "distributed_usage_explorer"
signozSpansTable = "distributed_signoz_spans"
@@ -4484,483 +4482,6 @@ func (r *ClickHouseReader) GetSpanAttributeKeysByNames(ctx context.Context, name
return response, nil
}
func (r *ClickHouseReader) AddRuleStateHistory(ctx context.Context, ruleStateHistory []model.RuleStateHistory) error {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "AddRuleStateHistory",
})
var statement driver.Batch
var err error
defer func() {
if statement != nil {
statement.Abort()
}
}()
statement, err = r.db.PrepareBatch(ctx, fmt.Sprintf("INSERT INTO %s.%s (rule_id, rule_name, overall_state, overall_state_changed, state, state_changed, unix_milli, labels, fingerprint, value) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
signozHistoryDBName, ruleStateHistoryTableName))
if err != nil {
return err
}
for _, history := range ruleStateHistory {
err = statement.Append(history.RuleID, history.RuleName, history.OverallState, history.OverallStateChanged, history.State, history.StateChanged, history.UnixMilli, history.Labels, history.Fingerprint, history.Value)
if err != nil {
return err
}
}
err = statement.Send()
if err != nil {
return err
}
return nil
}
func (r *ClickHouseReader) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]model.RuleStateHistory, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetLastSavedRuleStateHistory",
})
query := fmt.Sprintf("SELECT * FROM %s.%s WHERE rule_id = '%s' AND state_changed = true ORDER BY unix_milli DESC LIMIT 1 BY fingerprint",
signozHistoryDBName, ruleStateHistoryTableName, ruleID)
history := []model.RuleStateHistory{}
err := r.db.Select(ctx, &history, query)
if err != nil {
return nil, err
}
return history, nil
}
func (r *ClickHouseReader) ReadRuleStateHistoryByRuleID(
ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*model.RuleStateTimeline, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "ReadRuleStateHistoryByRuleID",
})
var conditions []string
conditions = append(conditions, fmt.Sprintf("rule_id = '%s'", ruleID))
conditions = append(conditions, fmt.Sprintf("unix_milli >= %d AND unix_milli < %d", params.Start, params.End))
if params.State != "" {
conditions = append(conditions, fmt.Sprintf("state = '%s'", params.State))
}
if params.Filters != nil && len(params.Filters.Items) != 0 {
for _, item := range params.Filters.Items {
toFormat := item.Value
op := v3.FilterOperator(strings.ToLower(strings.TrimSpace(string(item.Operator))))
if op == v3.FilterOperatorContains || op == v3.FilterOperatorNotContains {
toFormat = fmt.Sprintf("%%%s%%", toFormat)
}
fmtVal := utils.ClickHouseFormattedValue(toFormat)
switch op {
case v3.FilterOperatorEqual:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') = %s", item.Key.Key, fmtVal))
case v3.FilterOperatorNotEqual:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') != %s", item.Key.Key, fmtVal))
case v3.FilterOperatorIn:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') IN %s", item.Key.Key, fmtVal))
case v3.FilterOperatorNotIn:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') NOT IN %s", item.Key.Key, fmtVal))
case v3.FilterOperatorLike:
conditions = append(conditions, fmt.Sprintf("like(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorNotLike:
conditions = append(conditions, fmt.Sprintf("notLike(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorRegex:
conditions = append(conditions, fmt.Sprintf("match(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorNotRegex:
conditions = append(conditions, fmt.Sprintf("not match(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorGreaterThan:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') > %s", item.Key.Key, fmtVal))
case v3.FilterOperatorGreaterThanOrEq:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') >= %s", item.Key.Key, fmtVal))
case v3.FilterOperatorLessThan:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') < %s", item.Key.Key, fmtVal))
case v3.FilterOperatorLessThanOrEq:
conditions = append(conditions, fmt.Sprintf("JSONExtractString(labels, '%s') <= %s", item.Key.Key, fmtVal))
case v3.FilterOperatorContains:
conditions = append(conditions, fmt.Sprintf("like(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorNotContains:
conditions = append(conditions, fmt.Sprintf("notLike(JSONExtractString(labels, '%s'), %s)", item.Key.Key, fmtVal))
case v3.FilterOperatorExists:
conditions = append(conditions, fmt.Sprintf("has(JSONExtractKeys(labels), '%s')", item.Key.Key))
case v3.FilterOperatorNotExists:
conditions = append(conditions, fmt.Sprintf("not has(JSONExtractKeys(labels), '%s')", item.Key.Key))
default:
return nil, fmt.Errorf("unsupported filter operator")
}
}
}
whereClause := strings.Join(conditions, " AND ")
query := fmt.Sprintf("SELECT * FROM %s.%s WHERE %s ORDER BY unix_milli %s LIMIT %d OFFSET %d",
signozHistoryDBName, ruleStateHistoryTableName, whereClause, params.Order, params.Limit, params.Offset)
history := []model.RuleStateHistory{}
r.logger.Debug("rule state history query", "query", query)
err := r.db.Select(ctx, &history, query)
if err != nil {
r.logger.Error("Error while reading rule state history", errorsV2.Attr(err))
return nil, err
}
var total uint64
r.logger.Debug("rule state history total query", "query", fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE %s",
signozHistoryDBName, ruleStateHistoryTableName, whereClause))
err = r.db.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE %s",
signozHistoryDBName, ruleStateHistoryTableName, whereClause)).Scan(&total)
if err != nil {
return nil, err
}
labelsQuery := fmt.Sprintf("SELECT DISTINCT labels FROM %s.%s WHERE rule_id = $1",
signozHistoryDBName, ruleStateHistoryTableName)
rows, err := r.db.Query(ctx, labelsQuery, ruleID)
if err != nil {
return nil, err
}
defer rows.Close()
labelsMap := make(map[string][]string)
for rows.Next() {
var rawLabel string
err = rows.Scan(&rawLabel)
if err != nil {
return nil, err
}
label := map[string]string{}
err = json.Unmarshal([]byte(rawLabel), &label)
if err != nil {
return nil, err
}
for k, v := range label {
labelsMap[k] = append(labelsMap[k], v)
}
}
timeline := &model.RuleStateTimeline{
Items: history,
Total: total,
Labels: labelsMap,
}
return timeline, nil
}
func (r *ClickHouseReader) ReadRuleStateHistoryTopContributorsByRuleID(
ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.RuleStateHistoryContributor, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "ReadRuleStateHistoryTopContributorsByRuleID",
})
query := fmt.Sprintf(`SELECT
fingerprint,
any(labels) as labels,
count(*) as count
FROM %s.%s
WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d
GROUP BY fingerprint
HAVING labels != '{}'
ORDER BY count DESC`,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End)
r.logger.Debug("rule state history top contributors query", "query", query)
contributors := []model.RuleStateHistoryContributor{}
err := r.db.Select(ctx, &contributors, query)
if err != nil {
r.logger.Error("Error while reading rule state history", errorsV2.Attr(err))
return nil, err
}
return contributors, nil
}
func (r *ClickHouseReader) GetOverallStateTransitions(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.ReleStateItem, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetOverallStateTransitions",
})
tmpl := `WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT *
FROM matched_events
ORDER BY firing_time ASC;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End)
r.logger.Debug("overall state transitions query", "query", query)
transitions := []model.RuleStateTransition{}
err := r.db.Select(ctx, &transitions, query)
if err != nil {
return nil, err
}
stateItems := []model.ReleStateItem{}
for idx, item := range transitions {
start := item.FiringTime
end := item.ResolutionTime
stateItems = append(stateItems, model.ReleStateItem{
State: item.State,
Start: start,
End: end,
})
if idx < len(transitions)-1 {
nextStart := transitions[idx+1].FiringTime
if nextStart > end {
stateItems = append(stateItems, model.ReleStateItem{
State: model.StateInactive,
Start: end,
End: nextStart,
})
}
}
}
// fetch the most recent overall_state from the table
var state model.AlertState
stateQuery := fmt.Sprintf("SELECT state FROM %s.%s WHERE rule_id = '%s' AND unix_milli <= %d ORDER BY unix_milli DESC LIMIT 1",
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.End)
if err := r.db.QueryRow(ctx, stateQuery).Scan(&state); err != nil {
if err != sql.ErrNoRows {
return nil, err
}
state = model.StateInactive
}
if len(transitions) == 0 {
// no transitions found, it is either firing or inactive for whole time range
stateItems = append(stateItems, model.ReleStateItem{
State: state,
Start: params.Start,
End: params.End,
})
} else {
// there were some transitions, we need to add the last state at the end
if state == model.StateInactive {
stateItems = append(stateItems, model.ReleStateItem{
State: model.StateInactive,
Start: transitions[len(transitions)-1].ResolutionTime,
End: params.End,
})
} else {
// fetch the most recent firing event from the table in the given time range
var firingTime int64
firingQuery := fmt.Sprintf(`
SELECT
unix_milli
FROM %s.%s
WHERE rule_id = '%s' AND overall_state_changed = true AND overall_state = '%s' AND unix_milli <= %d
ORDER BY unix_milli DESC LIMIT 1`, signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.End)
if err := r.db.QueryRow(ctx, firingQuery).Scan(&firingTime); err != nil {
return nil, err
}
stateItems = append(stateItems, model.ReleStateItem{
State: model.StateInactive,
Start: transitions[len(transitions)-1].ResolutionTime,
End: firingTime,
})
stateItems = append(stateItems, model.ReleStateItem{
State: model.StateFiring,
Start: firingTime,
End: params.End,
})
}
}
return stateItems, nil
}
func (r *ClickHouseReader) GetAvgResolutionTime(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (float64, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetAvgResolutionTime",
})
tmpl := `
WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT AVG(resolution_time - firing_time) / 1000 AS avg_resolution_time
FROM matched_events;
`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End)
r.logger.Debug("avg resolution time query", "query", query)
var avgResolutionTime float64
err := r.db.QueryRow(ctx, query).Scan(&avgResolutionTime)
if err != nil {
return 0, err
}
return avgResolutionTime, nil
}
func (r *ClickHouseReader) GetAvgResolutionTimeByInterval(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*v3.Series, error) {
step := common.MinAllowedStepInterval(params.Start, params.End)
tmpl := `
WITH firing_events AS (
SELECT
rule_id,
state,
unix_milli AS firing_time
FROM %s.%s
WHERE overall_state = '` + model.StateFiring.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
resolution_events AS (
SELECT
rule_id,
state,
unix_milli AS resolution_time
FROM %s.%s
WHERE overall_state = '` + model.StateInactive.String() + `'
AND overall_state_changed = true
AND rule_id IN ('%s')
AND unix_milli >= %d AND unix_milli <= %d
),
matched_events AS (
SELECT
f.rule_id,
f.state,
f.firing_time,
MIN(r.resolution_time) AS resolution_time
FROM firing_events f
LEFT JOIN resolution_events r
ON f.rule_id = r.rule_id
WHERE r.resolution_time > f.firing_time
GROUP BY f.rule_id, f.state, f.firing_time
)
SELECT toStartOfInterval(toDateTime(firing_time / 1000), INTERVAL %d SECOND) AS ts, AVG(resolution_time - firing_time) / 1000 AS avg_resolution_time
FROM matched_events
GROUP BY ts
ORDER BY ts ASC;`
query := fmt.Sprintf(tmpl,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End,
signozHistoryDBName, ruleStateHistoryTableName, ruleID, params.Start, params.End, step)
r.logger.Debug("avg resolution time by interval query", "query", query)
result, err := r.GetTimeSeriesResultV3(ctx, query)
if err != nil || len(result) == 0 {
return nil, err
}
return result[0], nil
}
func (r *ClickHouseReader) GetTotalTriggers(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (uint64, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "clickhouse-reader",
instrumentationtypes.CodeFunctionName: "GetTotalTriggers",
})
query := fmt.Sprintf("SELECT count(*) FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d",
signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End)
var totalTriggers uint64
err := r.db.QueryRow(ctx, query).Scan(&totalTriggers)
if err != nil {
return 0, err
}
return totalTriggers, nil
}
func (r *ClickHouseReader) GetTriggersByInterval(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*v3.Series, error) {
step := common.MinAllowedStepInterval(params.Start, params.End)
query := fmt.Sprintf("SELECT count(*), toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), INTERVAL %d SECOND) as ts FROM %s.%s WHERE rule_id = '%s' AND (state_changed = true) AND (state = '%s') AND unix_milli >= %d AND unix_milli <= %d GROUP BY ts ORDER BY ts ASC",
step, signozHistoryDBName, ruleStateHistoryTableName, ruleID, model.StateFiring.String(), params.Start, params.End)
result, err := r.GetTimeSeriesResultV3(ctx, query)
if err != nil || len(result) == 0 {
return nil, err
}
return result[0], nil
}
func (r *ClickHouseReader) GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),

View File

@@ -15,7 +15,6 @@ import (
"github.com/SigNoz/signoz/pkg/queryparser"
"log/slog"
"math"
"net/http"
"regexp"
"slices"
@@ -39,7 +38,6 @@ import (
jsoniter "github.com/json-iterator/go"
_ "modernc.org/sqlite"
"github.com/SigNoz/signoz/pkg/contextlinks"
traceFunnelsModule "github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
queues2 "github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/queues"
@@ -64,7 +62,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
traceFunnels "github.com/SigNoz/signoz/pkg/types/tracefunneltypes"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/kafka"
@@ -370,10 +367,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}/history/stats", am.ViewAccess(aH.getRuleStats)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}/history/timeline", am.ViewAccess(aH.getRuleStateHistory)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}/history/top_contributors", am.ViewAccess(aH.getRuleStateHistoryTopContributors)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/rules/{id}/history/overall_status", am.ViewAccess(aH.getOverallStateTransitions)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/variables/query", am.ViewAccess(aH.queryDashboardVarsV2)).Methods(http.MethodPost)
@@ -663,217 +656,6 @@ func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
aH.Respond(w, response)
}
func (aH *APIHandler) getRuleStats(w http.ResponseWriter, r *http.Request) {
ruleID := mux.Vars(r)["id"]
params := model.QueryRuleStateHistory{}
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
totalCurrentTriggers, err := aH.reader.GetTotalTriggers(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
currentTriggersSeries, err := aH.reader.GetTriggersByInterval(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
currentAvgResolutionTime, err := aH.reader.GetAvgResolutionTime(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
currentAvgResolutionTimeSeries, err := aH.reader.GetAvgResolutionTimeByInterval(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
if params.End-params.Start >= 86400000 {
days := int64(math.Ceil(float64(params.End-params.Start) / 86400000))
params.Start -= days * 86400000
params.End -= days * 86400000
} else {
params.Start -= 86400000
params.End -= 86400000
}
totalPastTriggers, err := aH.reader.GetTotalTriggers(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
pastTriggersSeries, err := aH.reader.GetTriggersByInterval(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
pastAvgResolutionTime, err := aH.reader.GetAvgResolutionTime(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
pastAvgResolutionTimeSeries, err := aH.reader.GetAvgResolutionTimeByInterval(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
if math.IsNaN(currentAvgResolutionTime) || math.IsInf(currentAvgResolutionTime, 0) {
currentAvgResolutionTime = 0
}
if math.IsNaN(pastAvgResolutionTime) || math.IsInf(pastAvgResolutionTime, 0) {
pastAvgResolutionTime = 0
}
stats := model.Stats{
TotalCurrentTriggers: totalCurrentTriggers,
TotalPastTriggers: totalPastTriggers,
CurrentTriggersSeries: currentTriggersSeries,
PastTriggersSeries: pastTriggersSeries,
CurrentAvgResolutionTime: strconv.FormatFloat(currentAvgResolutionTime, 'f', -1, 64),
PastAvgResolutionTime: strconv.FormatFloat(pastAvgResolutionTime, 'f', -1, 64),
CurrentAvgResolutionTimeSeries: currentAvgResolutionTimeSeries,
PastAvgResolutionTimeSeries: pastAvgResolutionTimeSeries,
}
aH.Respond(w, stats)
}
func (aH *APIHandler) getOverallStateTransitions(w http.ResponseWriter, r *http.Request) {
ruleID := mux.Vars(r)["id"]
params := model.QueryRuleStateHistory{}
err := json.NewDecoder(r.Body).Decode(&params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
stateItems, err := aH.reader.GetOverallStateTransitions(r.Context(), ruleID, &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
aH.Respond(w, stateItems)
}
func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
params := model.QueryRuleStateHistory{}
err = json.NewDecoder(r.Body).Decode(&params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
if err := params.Validate(); err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
res, err := aH.reader.ReadRuleStateHistoryByRuleID(r.Context(), id.StringValue(), &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
rule, err := aH.ruleManager.GetRule(r.Context(), id)
if err == nil {
for idx := range res.Items {
lbls := make(map[string]string)
err := json.Unmarshal([]byte(res.Items[idx].Labels), &lbls)
if err != nil {
continue
}
end := time.Unix(res.Items[idx].UnixMilli/1000, 0)
// why are we subtracting 3 minutes?
// the query range is calculated based on the rule's evalWindow and evalDelay
// alerts have 2 minutes delay built in, so we need to subtract that from the start time
// to get the correct query range
start := end.Add(-rule.EvalWindow.Duration() - 3*time.Minute)
if rule.AlertType == ruletypes.AlertTypeLogs {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
}
}
}
filterExpr := ""
if q.Filter != nil && q.Filter.Expression != "" {
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
}
}
}
aH.Respond(w, res)
}
func (aH *APIHandler) getRuleStateHistoryTopContributors(w http.ResponseWriter, r *http.Request) {
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
params := model.QueryRuleStateHistory{}
err = json.NewDecoder(r.Body).Decode(&params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
return
}
res, err := aH.reader.ReadRuleStateHistoryTopContributorsByRuleID(r.Context(), id.StringValue(), &params)
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return
}
aH.Respond(w, res)
}
func prepareQuery(r *http.Request) (string, error) {
var postData *model.DashboardVars

View File

@@ -79,16 +79,6 @@ type Reader interface {
GetMetricMetadata(context.Context, valuer.UUID, string, string) (*v3.MetricMetadataResponse, error)
AddRuleStateHistory(ctx context.Context, ruleStateHistory []model.RuleStateHistory) error
GetOverallStateTransitions(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.ReleStateItem, error)
ReadRuleStateHistoryByRuleID(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*model.RuleStateTimeline, error)
GetTotalTriggers(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (uint64, error)
GetTriggersByInterval(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*v3.Series, error)
GetAvgResolutionTime(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (float64, error)
GetAvgResolutionTimeByInterval(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) (*v3.Series, error)
ReadRuleStateHistoryTopContributorsByRuleID(ctx context.Context, ruleID string, params *model.QueryRuleStateHistory) ([]model.RuleStateHistoryContributor, error)
GetLastSavedRuleStateHistory(ctx context.Context, ruleID string) ([]model.RuleStateHistory, error)
GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error)
//trace

View File

@@ -1,202 +0,0 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/pkg/errors"
)
// AlertState denotes the state of an active alert.
type AlertState int
// The enum values are ordered by priority (lowest to highest).
// When determining overall rule state, higher numeric values take precedence.
const (
StateInactive AlertState = iota
StatePending
StateRecovering
StateFiring
StateNoData
StateDisabled
)
func (s AlertState) String() string {
switch s {
case StateInactive:
return "inactive"
case StatePending:
return "pending"
case StateFiring:
return "firing"
case StateNoData:
return "nodata"
case StateDisabled:
return "disabled"
case StateRecovering:
return "recovering"
}
panic(errors.Errorf("unknown alert state: %d", s))
}
func (s AlertState) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
func (s *AlertState) UnmarshalJSON(b []byte) error {
var v interface{}
if err := json.Unmarshal(b, &v); err != nil {
return err
}
switch value := v.(type) {
case string:
switch value {
case "inactive":
*s = StateInactive
case "pending":
*s = StatePending
case "firing":
*s = StateFiring
case "nodata":
*s = StateNoData
case "disabled":
*s = StateDisabled
case "recovering":
*s = StateRecovering
default:
*s = StateInactive
}
return nil
default:
return errors.New("invalid alert state")
}
}
func (s *AlertState) Scan(value interface{}) error {
v, ok := value.(string)
if !ok {
return errors.New("invalid alert state")
}
switch v {
case "inactive":
*s = StateInactive
case "pending":
*s = StatePending
case "firing":
*s = StateFiring
case "nodata":
*s = StateNoData
case "disabled":
*s = StateDisabled
case "recovering":
*s = StateRecovering
}
return nil
}
func (s *AlertState) Value() (driver.Value, error) {
return s.String(), nil
}
type LabelsString string
func (l *LabelsString) MarshalJSON() ([]byte, error) {
lbls := make(map[string]string)
err := json.Unmarshal([]byte(*l), &lbls)
if err != nil {
return nil, err
}
return json.Marshal(lbls)
}
func (l *LabelsString) Scan(src interface{}) error {
if data, ok := src.(string); ok {
*l = LabelsString(data)
}
return nil
}
func (l LabelsString) String() string {
return string(l)
}
type RuleStateTimeline struct {
Items []RuleStateHistory `json:"items"`
Total uint64 `json:"total"`
Labels map[string][]string `json:"labels"`
}
type RuleStateHistory struct {
RuleID string `json:"ruleID" ch:"rule_id"`
RuleName string `json:"ruleName" ch:"rule_name"`
// One of ["normal", "firing"]
OverallState AlertState `json:"overallState" ch:"overall_state"`
OverallStateChanged bool `json:"overallStateChanged" ch:"overall_state_changed"`
// One of ["normal", "firing", "nodata", "muted"]
State AlertState `json:"state" ch:"state"`
StateChanged bool `json:"stateChanged" ch:"state_changed"`
UnixMilli int64 `json:"unixMilli" ch:"unix_milli"`
Labels LabelsString `json:"labels" ch:"labels"`
Fingerprint uint64 `json:"fingerprint" ch:"fingerprint"`
Value float64 `json:"value" ch:"value"`
RelatedTracesLink string `json:"relatedTracesLink"`
RelatedLogsLink string `json:"relatedLogsLink"`
}
type QueryRuleStateHistory struct {
Start int64 `json:"start"`
End int64 `json:"end"`
State string `json:"state"`
Filters *v3.FilterSet `json:"filters"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
Order string `json:"order"`
}
func (r *QueryRuleStateHistory) Validate() error {
if r.Start == 0 || r.End == 0 {
return fmt.Errorf("start and end are required")
}
if r.Offset < 0 || r.Limit < 0 {
return fmt.Errorf("offset and limit must be greater than 0")
}
if r.Order != "asc" && r.Order != "desc" {
return fmt.Errorf("order must be asc or desc")
}
return nil
}
type RuleStateHistoryContributor struct {
Fingerprint uint64 `json:"fingerprint" ch:"fingerprint"`
Labels LabelsString `json:"labels" ch:"labels"`
Count uint64 `json:"count" ch:"count"`
RelatedTracesLink string `json:"relatedTracesLink"`
RelatedLogsLink string `json:"relatedLogsLink"`
}
type RuleStateTransition struct {
RuleID string `json:"ruleID" ch:"rule_id"`
State AlertState `json:"state" ch:"state"`
FiringTime int64 `json:"firingTime" ch:"firing_time"`
ResolutionTime int64 `json:"resolutionTime" ch:"resolution_time"`
}
type ReleStateItem struct {
State AlertState `json:"state"`
Start int64 `json:"start"`
End int64 `json:"end"`
}
type Stats struct {
TotalCurrentTriggers uint64 `json:"totalCurrentTriggers"`
TotalPastTriggers uint64 `json:"totalPastTriggers"`
CurrentTriggersSeries *v3.Series `json:"currentTriggersSeries"`
PastTriggersSeries *v3.Series `json:"pastTriggersSeries"`
CurrentAvgResolutionTime string `json:"currentAvgResolutionTime"`
PastAvgResolutionTime string `json:"pastAvgResolutionTime"`
CurrentAvgResolutionTimeSeries *v3.Series `json:"currentAvgResolutionTimeSeries"`
PastAvgResolutionTimeSeries *v3.Series `json:"pastAvgResolutionTimeSeries"`
}

View File

@@ -97,8 +97,8 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
options.MaxIdleConns = config.Connection.MaxIdleConns
options.MaxOpenConns = config.Connection.MaxOpenConns
options.DialTimeout = config.Connection.DialTimeout
// This is to avoid the driver decoding issues with JSON columns
options.Settings["output_format_native_write_json_as_string"] = 1
// Native flattened JSON serialization (CH 25.6+): clickhouse-go mis-decodes JSON(max_dynamic_paths=0) columns without it.
options.Settings["output_format_native_use_flattened_dynamic_and_json_serialization"] = 1
chConn, err := clickhouse.Open(options)
if err != nil {
@@ -184,7 +184,7 @@ func (p *provider) Query(ctx context.Context, query string, args ...interface{})
}
return &rowsWithHooks{
Rows: telemetrystore.WrapRows(rows),
Rows: rows,
ctx: ctx,
event: event,
onClose: func() { telemetrystore.WrapAfterQuery(p.hooks, ctx, event) },

View File

@@ -1,39 +0,0 @@
package telemetrystore
import (
"reflect"
"strings"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
)
// WrapRows reports JSONValue as the scan type of every JSON column. Nested JSON — Array(JSON),
// Map(String, JSON) — is not covered.
func WrapRows(rows driver.Rows) driver.Rows {
return &rowsWithJSONScanType{Rows: rows}
}
type rowsWithJSONScanType struct {
driver.Rows
}
func (r *rowsWithJSONScanType) ColumnTypes() []driver.ColumnType {
colTypes := r.Rows.ColumnTypes()
wrapped := make([]driver.ColumnType, len(colTypes))
for i, colType := range colTypes {
wrapped[i] = colType
if strings.HasPrefix(strings.ToUpper(colType.DatabaseTypeName()), "JSON") {
wrapped[i] = jsonColumnType{ColumnType: colType}
}
}
return wrapped
}
type jsonColumnType struct {
driver.ColumnType
}
func (jsonColumnType) ScanType() reflect.Type {
return reflect.TypeFor[telemetrystoretypes.JSONValue]()
}

View File

@@ -1,23 +0,0 @@
package telemetrystoretest
import (
"context"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/telemetrystore"
)
// conn wraps rows the way the clickhouse provider does, so mocked JSON columns report the scan
// type they do in production.
type conn struct {
clickhouse.Conn
}
func (c conn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
rows, err := c.Conn.Query(ctx, query, args...)
if err != nil {
return nil, err
}
return telemetrystore.WrapRows(rows), nil
}

View File

@@ -32,7 +32,7 @@ func New(_ telemetrystore.Config, matcher sqlmock.QueryMatcher) *Provider {
// ClickhouseDB returns the mock Clickhouse connection.
func (p *Provider) ClickhouseDB() clickhouse.Conn {
return conn{Conn: p.clickhouseDB.(clickhouse.Conn)}
return p.clickhouseDB.(clickhouse.Conn)
}
// Cluster returns the cluster name.

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
@@ -34,20 +54,43 @@ 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.
// holds the span's JSON attributes keyed by full dotted path. Each 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
}
@@ -106,19 +221,73 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
return OutputMessages{genericMessage(value)}, FormatterGeneric
}
// attribute walks the document along the dotted key; a nested object comes back whole. Not handled: a
// scalar at a prefix of the path, such as gen_ai.input beside gen_ai.input.messages, which ends the
// walk early.
// attribute reads one attribute by its full dotted path from the flat JSON document, resolving the
// driver's Variant wrappers to the plain Go types the formatter asserts on.
func attribute(doc map[string]any, key string) any {
var current any = doc
for segment := range strings.SplitSeq(key, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[segment]
return plainJSON(doc[key])
}
// indexed reads the list written as "<key>.<N>.<field>" keys, one object per N in numeric order. Inside an
// entry that nested rebuilt, the list is already an object keyed by N.
func indexed(doc map[string]any, key string) []map[string]any {
object, ok := attribute(doc, key).(map[string]any)
if !ok {
object = nested(doc, key+".")
}
return current
return byIndex(object)
}
// indexedAt reads one entry of a flattened list by its index.
func indexedAt(doc map[string]any, key string, i int) map[string]any {
return nested(doc, key+"."+strconv.Itoa(i)+".")
}
// nested rebuilds the keys under prefix into one object, split on their dots. When a key is both a value
// and the parent of other keys, the object wins, so the result does not depend on map order.
func nested(doc map[string]any, prefix string) map[string]any {
out := map[string]any{}
for key := range doc {
rest, ok := strings.CutPrefix(key, prefix)
if !ok || rest == "" {
continue
}
segments := strings.Split(rest, ".")
current := out
for _, segment := range segments[:len(segments)-1] {
child, ok := current[segment].(map[string]any)
if !ok {
child = map[string]any{}
current[segment] = child
}
current = child
}
leaf := segments[len(segments)-1]
if _, ok := current[leaf].(map[string]any); !ok {
current[leaf] = attribute(doc, key)
}
}
return out
}
// byIndex lists the object entries keyed by a numeric index, in index order.
func byIndex(object map[string]any) []map[string]any {
indexes := make([]int, 0, len(object))
items := make(map[int]map[string]any, len(object))
for k, item := range object {
n, err := strconv.Atoi(k)
entry, isObject := item.(map[string]any)
if err != nil || !isObject {
continue
}
indexes = append(indexes, n)
items[n] = entry
}
sort.Ints(indexes)
out := make([]map[string]any, 0, len(indexes))
for _, n := range indexes {
out = append(out, items[n])
}
return out
}
func firstObject(list []any) (map[string]any, bool) {
@@ -133,8 +302,33 @@ 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.
// Anything that is not a JSON string is returned as is.
func decode(value any) any {
s, ok := value.(string)
if !ok {
@@ -154,6 +348,20 @@ func decode(value any) any {
return decoded
}
// plainJSON round-trips a structured value through encoding/json, turning wrapper types such as the
// ClickHouse driver's JSON Variants into plain Go types. Values that cannot be marshaled pass through.
func plainJSON(value any) any {
raw, err := json.Marshal(value)
if err != nil {
return value
}
var decoded any
if err := json.Unmarshal(raw, &decoded); err != nil {
return value
}
return decoded
}
func role(value string) Role {
lower := strings.ToLower(strings.TrimSpace(value))
switch lower {
@@ -179,6 +387,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 +420,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 +484,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 +499,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 +530,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,140 @@
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":
// the URL sits at message_content.<type>.<type>.url
media, _ := content[typ].(map[string]any)
inner, _ := media[typ].(map[string]any)
if url := stringOf(inner["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

@@ -3,7 +3,6 @@ package aiobservabilitytypes
import (
"encoding/json"
"maps"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -26,7 +25,7 @@ func runFormatCases(t *testing.T, testCases []formatCase) {
t.Run(testCase.name, func(t *testing.T) {
attributes := map[string]any{GenAIInputMessages: testCase.input, GenAIOutputMessages: testCase.output}
maps.Copy(attributes, testCase.attributes)
formatted := FormatMessages(document(attributes))
formatted := FormatMessages(attributes)
assert.Equal(t, append([]string{}, testCase.warnings...), formatted.Warnings)
input, err := json.Marshal(formatted.Input)
@@ -39,25 +38,6 @@ func runFormatCases(t *testing.T, testCases []formatCase) {
}
}
// document nests flat dotted keys the way the JSON column stores them.
func document(flat map[string]any) map[string]any {
doc := map[string]any{}
for key, value := range flat {
segments := strings.Split(key, ".")
current := doc
for _, segment := range segments[:len(segments)-1] {
child, ok := current[segment].(map[string]any)
if !ok {
child = map[string]any{}
current[segment] = child
}
current = child
}
current[segments[len(segments)-1]] = value
}
return doc
}
func TestFormat(t *testing.T) {
runFormatCases(t, []formatCase{
{
@@ -112,6 +92,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 +110,113 @@ 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: 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: "ScalarAtKey_EntriesBesideItRead",
doc: map[string]any{"llm.input_messages": "raw", "llm.input_messages.0.message.role": "user"},
want: []map[string]any{{"message": map[string]any{"role": "user"}}},
},
{
name: "ScalarAndObjectAtSamePath_ObjectKept",
doc: map[string]any{"llm.input_messages.0.message": "raw", "llm.input_messages.0.message.role": "user"},
want: []map[string]any{{"message": map[string]any{"role": "user"}}},
},
{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",
@@ -76,17 +78,6 @@ func TestFormat_Tool(t *testing.T) {
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "arguments": {"city": "Bengaluru", "unit": "c"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_l0ycKcmt1uPCdGWR4SkwoLBv", "response": {"city": "Bengaluru", "unit": "c", "temperature": 27, "condition": "partly cloudy"}}]}]`,
},
{
// the object form the OTel registry asks for, whole in the JSON column
name: "StructuredArguments_ObjectKept",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallID: "call_1",
GenAIToolCallArguments: map[string]any{"city": "Paris", "days": []any{1.0, 2.0}},
},
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Paris", "days": [1, 2]}}]}]`,
wantOutput: `[]`,
},
{
// the mapper groups never fill both; the tool call wins when an SDK does
name: "MessagesBesideToolCall_ToolCallFormatted",

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,189 @@
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"
)
// vercel detects the AI SDK by ai.operationId, which every span it emits carries:
// https://github.com/vercel/ai/blob/a5e92fe3c692f8e152fe1eb9ef50a4e734ad0ffb/packages/ai/src/telemetry/assemble-operation-name.ts#L18
func (f *formatting) vercel() bool {
if attribute(f.attributes, vercelOperation) == 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

@@ -123,22 +123,10 @@ func (d *DashboardSpec) validatePanels() error {
return err
}
}
// validatePanelQueryCount left exactly one query for a spec that reads it.
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
return err
}
}
}
return nil
}
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
// query. It is checked after the query itself validates.
type queryBoundPanelSpec interface {
validateAgainstQuery(query Query, path string) error
}
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
if queries == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)

View File

@@ -1870,319 +1870,6 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
}
}
func TestScatterPlotPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {}
},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
}
func TestValidateScatterPlotDashboard(t *testing.T) {
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
require.NoError(t, err, "reading example file")
spec, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
softMin, softMax := 0.0, 1000.0
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
assert.Equal(t, &ScatterPlotPanelSpec{
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
Axes: ScatterPlotAxes{
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
},
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
Thresholds: []ScatterPlotThreshold{
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
},
}, panelSpec)
dashboard := &DashboardV2{Spec: *spec}
request, err := dashboard.GetPanelQuery(1, 2, "p1")
require.NoError(t, err, "building the panel's query failed")
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
require.NotNil(t, request.FormatOptions)
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
require.Len(t, request.CompositeQuery.Queries, 1)
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
require.True(t, ok, "expected a traces builder query")
assert.Len(t, query.Aggregations, 3)
assert.Len(t, query.GroupBy, 2)
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
// the panel read back out of storage draws the same scatter plot
stored, err := json.Marshal(spec)
require.NoError(t, err, "marshal dashboard failed")
reread, err := unmarshalDashboard(stored)
require.NoError(t, err, "the stored dashboard does not validate")
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
require.NoError(t, err, "building the stored panel's query failed")
assert.Equal(t, request, rereadRequest)
}
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
tests := []struct {
scenario string
panelKind string
panelSpec string
expectedErrorSubstring string
}{
{
scenario: "unknown axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
},
{
scenario: "non-string axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"y": {"scale": true}}}`,
expectedErrorSubstring: "cannot unmarshal bool",
},
{
scenario: "isLogScale on a scatter plot axis",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "single-axis axes shape on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"softMin": 0}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "unknown threshold axis",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
},
{
scenario: "unknown dimension",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"z": "A"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "point size below the minimum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
},
{
scenario: "max point size above the maximum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
expectedErrorSubstring: "invalid point diameter 41",
},
{
scenario: "fractional point size",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
expectedErrorSubstring: "cannot unmarshal number 4.5",
},
{
scenario: "min point size above max",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
},
{
scenario: "point opacity below the floor",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
},
{
scenario: "point opacity as a percentage",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
expectedErrorSubstring: "invalid point opacity 70",
},
{
scenario: "area fill fields on a scatter plot",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "a single colour key as a string",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
expectedErrorSubstring: "cannot unmarshal string",
},
{
scenario: "panel-wide unit on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"formatting": {"unit": "ms"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "threshold without a color",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"thresholds": [{"value": 300}]}`,
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
},
{
scenario: "scale on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"axes": {"scale": "log"}}`,
expectedErrorSubstring: "unknown field",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected the spec to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
tests := []struct {
scenario string
queryPlugin string
dimensions string
expectedErrorSubstring string
}{
{
scenario: "a single-aggregation query is one value column keyed by its name",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
dimensions: `{"x": "A", "colorBy": ["service.name"]}`,
},
{
scenario: "a multi-aggregation query keys each value column by name and expression",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
dimensions: `{"x": "A"}`,
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
},
{
scenario: "a value column of a query the panel does not have",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}`,
dimensions: `{"y": "B"}`,
expectedErrorSubstring: "dimensions.y: \"B\" is not a value column of the panel's query; value columns: `A`",
},
{
scenario: "a colour key the query does not group by",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
},
{
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
]}}`,
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
},
{
scenario: "a disabled query yields no value column",
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": true, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
]}}`,
dimensions: `{"sizeBy": "A"}`,
expectedErrorSubstring: "dimensions.sizeBy: \"A\" is not a value column of the panel's query; value columns: `F1`",
},
{
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
if test.expectedErrorSubstring == "" {
require.NoError(t, err)
return
}
require.Error(t, err, "expected the binding to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
@@ -2541,7 +2228,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
switch panelKind {
case "signoz/ListPanel":
return "raw"
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
return "scalar"
default:
return "time_series"
@@ -2585,17 +2272,14 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
// Top-level: rejected
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
// Composite sub-queries
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
}

View File

@@ -28,17 +28,16 @@ type PanelPlugin struct {
// (and strips the duplicate parent properties) after reflection.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
})
}
@@ -72,7 +71,6 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
}
}
@@ -236,17 +234,16 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
@@ -263,17 +260,16 @@ var (
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
PanelKindText: {},
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
PanelKindText: {},
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
}
)

View File

@@ -221,7 +221,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
CompositeQuery: composite,
FormatOptions: &qb.FormatOptions{
FillGaps: fillGaps,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
},
}, nil
}

View File

@@ -251,38 +251,34 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
})
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
t.Run(string(kind), func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: kind},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTable},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
},
},
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
})
},
},
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
})
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {

View File

@@ -2,8 +2,6 @@ package dashboardtypes
import (
"encoding/json"
"maps"
"slices"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
@@ -189,21 +187,20 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
type PanelPluginKind string
const (
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
}
func (k PanelPluginKind) rendersWithoutQuery() bool {
@@ -353,95 +350,6 @@ type TextPanelSpec struct {
HeaderOptions HeaderOptions `json:"headerOptions"`
}
type ScatterPlotPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Dimensions ScatterPlotDimensions `json:"dimensions"`
Formatting TableFormatting `json:"formatting"`
Axes ScatterPlotAxes `json:"axes"`
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
Legend Legend `json:"legend"`
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
}
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
}
keys, known := composite.CollectResultColumnKeys()
if !known {
return nil
}
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
// Empty is unset: the renderer picks a column or a fixed size.
if binding.key == "" {
continue
}
if _, ok := keys.Aggregations[binding.key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
}
}
for _, label := range s.Dimensions.ColorBy {
if _, ok := keys.Groups[label]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
}
}
return nil
}
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
// use the Table column key, so formatting.columnUnits carries over between the
// two kinds.
type ScatterPlotDimensions struct {
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
}
type ScatterPlotAxes struct {
X ScatterPlotAxis `json:"x"`
Y ScatterPlotAxis `json:"y"`
}
type ScatterPlotAxis struct {
SoftMin *float64 `json:"softMin"`
SoftMax *float64 `json:"softMax"`
Scale AxisScale `json:"scale"`
Label string `json:"label" description:"Axis title. Empty draws none."`
}
type ScatterPlotChartAppearance struct {
Points ScatterPlotPoints `json:"points"`
}
// ScatterPlotPoints keeps the fixed size and the size range side by side so
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
// fields resolve to the renderer default.
type ScatterPlotPoints struct {
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
Opacity *PointOpacity `json:"opacity"`
}
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
type alias ScatterPlotPoints
var tmp alias
if err := json.Unmarshal(data, &tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
}
*p = ScatterPlotPoints(tmp)
return p.validate()
}
func (p ScatterPlotPoints) validate() error {
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
}
return nil
}
type TextPresentation struct {
TextAlign TextAlign `json:"textAlign"`
VerticalAlign VerticalAlign `json:"verticalAlign"`
@@ -523,11 +431,6 @@ type ComparisonThreshold struct {
Format ThresholdFormat `json:"format"`
}
type ScatterPlotThreshold struct {
ThresholdWithLabel
Axis ThresholdAxis `json:"axis"`
}
type TableThreshold struct {
ComparisonThreshold
ColumnName string `json:"columnName" validate:"required" required:"true"`
@@ -813,86 +716,6 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
}
}
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
// defined at and below zero.
type AxisScale struct{ valuer.String }
var (
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
AxisScaleLog = AxisScale{valuer.NewString("log")}
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
)
func (AxisScale) Enum() []any {
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
}
func (as AxisScale) ValueOrDefault() string {
if as.IsZero() {
return AxisScaleAuto.StringValue()
}
return as.StringValue()
}
func (as AxisScale) MarshalJSON() ([]byte, error) {
return json.Marshal(as.ValueOrDefault())
}
func (as *AxisScale) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
}
val := AxisScale{valuer.NewString(v)}
switch val {
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
*as = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
}
}
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
// horizontally, `x` vertically.
type ThresholdAxis struct{ valuer.String }
var (
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
)
func (ThresholdAxis) Enum() []any {
return []any{ThresholdAxisX, ThresholdAxisY}
}
func (ta ThresholdAxis) ValueOrDefault() string {
if ta.IsZero() {
return ThresholdAxisY.StringValue()
}
return ta.StringValue()
}
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
return json.Marshal(ta.ValueOrDefault())
}
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
}
val := ThresholdAxis{valuer.NewString(v)}
switch val {
case ThresholdAxisX, ThresholdAxisY:
*ta = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
}
}
type FillMode struct{ valuer.String }
var (
@@ -1031,53 +854,6 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
return nil
}
const (
minPointDiameter = 2
maxPointDiameter = 40
)
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
type PointDiameter int
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
return nil
}
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
var v int
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
}
if v < minPointDiameter || v > maxPointDiameter {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
}
*d = PointDiameter(v)
return nil
}
const minPointOpacity = 0.1
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
type PointOpacity float64
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointOpacity).WithMaximum(1)
return nil
}
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
}
if v < minPointOpacity || v > 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
}
*o = PointOpacity(v)
return nil
}
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`

View File

@@ -1,134 +0,0 @@
{
"display": {
"name": "service health",
"description": "throughput against tail latency per pod"
},
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"display": {
"name": "throughput vs p99",
"description": ""
},
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {
"visualization": {
"timePreference": "last_1_hr"
},
"dimensions": {
"x": "A.count()",
"y": "A.p99(duration_nano)",
"sizeBy": "A.countIf(has_error = true)",
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
},
"formatting": {
"columnUnits": {
"A.p99(duration_nano)": "ns"
},
"decimalPrecision": "1"
},
"axes": {
"x": {
"softMin": 0,
"softMax": null,
"scale": "log",
"label": "Throughput"
},
"y": {
"softMin": null,
"softMax": 1000,
"scale": "symlog",
"label": "p99 latency"
}
},
"chartAppearance": {
"points": {
"size": 8,
"minSize": 4,
"maxSize": 24,
"opacity": 0.1
}
},
"legend": {
"position": "right",
"mode": "list",
"customColors": {
"checkout": "#1f77b4"
}
},
"thresholds": [
{
"value": 300,
"unit": "ms",
"color": "#f00",
"label": "p99 SLO",
"axis": "y"
},
{
"value": 50,
"color": "#0f0",
"axis": "x"
}
]
}
},
"links": [],
"queries": [
{
"kind": "scalar",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count()"
},
{
"expression": "p99(duration_nano)"
},
{
"expression": "countIf(has_error = true)"
}
],
"groupBy": [
{
"name": "k8s.namespace.name"
},
{
"name": "k8s.pod.name"
}
]
}
}
}
}
]
}
}
},
"links": [],
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"content": {
"$ref": "#/spec/panels/p1"
}
}
]
}
}
]
}

View File

@@ -266,43 +266,6 @@ type CompositeQuery struct {
Queries []QueryEnvelope `json:"queries"`
}
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
// UI keys columns: an aggregation column is the query name, or
// queryName.expression when the query has several aggregations; a group
// column is a group-by key name.
type ResultColumnKeys struct {
Aggregations map[string]struct{}
Groups map[string]struct{}
}
// CollectResultColumnKeys reports false when a query's columns are only known
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
// aggregation column but keeps its group columns, since a formula over it
// still carries them.
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
for _, envelope := range c.Queries {
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
return ResultColumnKeys{}, false
}
for _, key := range envelope.GetGroupBy() {
keys.Groups[key.Name] = struct{}{}
}
if envelope.IsDisabled() {
continue
}
expressions := envelope.GetAggregationExpressions()
if len(expressions) <= 1 {
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
continue
}
for _, expression := range expressions {
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
}
}
return keys, true
}
// PrepareJSONSchema adds description to the CompositeQuery schema.
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")

View File

@@ -225,34 +225,6 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
return nil
}
// GetAggregationExpressions returns one entry per aggregation. A metric
// aggregation has no expression, so it contributes an empty entry.
func (q *QueryEnvelope) GetAggregationExpressions() []string {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[TraceAggregation]:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[LogAggregation]:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[MetricAggregation]:
return make([]string, len(spec.Aggregations))
}
return nil
}
// GetFilter returns the filter.
func (q *QueryEnvelope) GetFilter() *Filter {
switch spec := q.Spec.(type) {

View File

@@ -1903,63 +1903,3 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
testCases := []struct {
scenario string
composite CompositeQuery
expectedKeys ResultColumnKeys
expectedKnown bool
}{
{
scenario: "a single aggregation is keyed by the query name",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Aggregations: []TraceAggregation{{Expression: "count()"}},
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
}}}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A": {}}, Groups: map[string]struct{}{"service.name": {}}},
expectedKnown: true,
},
{
scenario: "several aggregations are keyed by query name and expression",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
}}}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A.count()": {}, "A.p99(duration_nano)": {}}, Groups: map[string]struct{}{}},
expectedKnown: true,
},
{
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
composite: CompositeQuery{Queries: []QueryEnvelope{
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}},
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
}},
{Type: QueryTypeFormula, Spec: QueryBuilderFormula{Name: "F1", Expression: "A * 2"}},
}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"F1": {}}, Groups: map[string]struct{}{"service.name": {}}},
expectedKnown: true,
},
{
scenario: "clickhouse columns are only known at run time",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
Name: "A",
Query: "SELECT 1",
}}}},
expectedKeys: ResultColumnKeys{},
expectedKnown: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
keys, known := testCase.composite.CollectResultColumnKeys()
assert.Equal(t, testCase.expectedKnown, known)
assert.Equal(t, testCase.expectedKeys, keys)
})
}
}

View File

@@ -178,9 +178,8 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
maps.Copy(resources, storable.ResourcesString)
timeUnixNano := uint64(storable.StartTime.UnixNano())
attributes := threadAttributes(storable)
// Spans from before the JSON rollout have no document and are not decoded: the legacy maps
// hold an object split into one key per field.
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON)
// Pre-rollout spans have an empty JSON document and format to nothing.
formatted := aiobservabilitytypes.FormatMessages(FlatJSONAttributes(storable.AttributesJSON))
return &ThreadSpan{
SpanID: storable.SpanID,
TraceID: traceID,
@@ -203,13 +202,12 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
}
}
// threadAttributes flattens the JSON column into dotted keys, as the querier does for list
// threadAttributes returns the span's flat dotted attributes, as the querier does for list
// responses, and falls back to the legacy maps for spans written before the JSON rollout.
func threadAttributes(storable *StorableSpan) map[string]any {
if len(storable.AttributesJSON) == 0 {
attributes := FlatJSONAttributes(storable.AttributesJSON)
if len(attributes) == 0 {
return storable.Attributes()
}
attributes := make(map[string]any, len(storable.AttributesJSON))
storable.AttributesJSON.FlattenInto("", attributes)
return attributes
}

View File

@@ -3,11 +3,21 @@ package spantypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// jsonAttrs builds a native JSON column from dotted leaf paths, as ClickHouse stores them.
func jsonAttrs(paths map[string]any) chcol.JSON {
j := chcol.NewJSON()
for path, value := range paths {
j.SetValueAtPath(path, value)
}
return *j
}
func TestNewThreadQuery_Cursor(t *testing.T) {
testCases := []struct {
name string
@@ -48,7 +58,7 @@ func TestThreadAttributes(t *testing.T) {
{
name: "JSONColumnPresent_LegacyMapsIgnored",
span: StorableSpan{
AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"request": map[string]any{"model": "json"}}},
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "json"}),
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
},
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
@@ -66,6 +76,22 @@ func TestThreadAttributes(t *testing.T) {
"gen_ai.stream": true,
},
},
{
// ClickHouse materializes a row's absent paths as null from the part's path schema.
name: "JSONColumn_DropsNullPaths",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": chcol.Variant{}}),
},
wantAttrs: map[string]any{"gen_ai.request.model": "gpt-4o"},
},
{
name: "JSONColumn_OnlyNullPaths_FallsBackToLegacyMaps",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.output.messages": chcol.Variant{}}),
AttributesString: map[string]string{"gen_ai.input.messages": "legacy"},
},
wantAttrs: map[string]any{"gen_ai.input.messages": "legacy"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
@@ -73,3 +99,20 @@ func TestThreadAttributes(t *testing.T) {
})
}
}
func TestNewThreadSpan_FormatsJSONMessages(t *testing.T) {
storable := StorableSpan{
SpanID: "f1fa1bc863e94dd0",
AttributesJSON: jsonAttrs(map[string]any{
"gen_ai.input.messages": []any{map[string]any{"role": "user", "content": "hi"}},
"gen_ai.output.messages": []any{map[string]any{"role": "assistant", "content": "hello"}},
}),
}
span := newThreadSpan("trace", &storable)
require.Len(t, span.FormattedInput, 1)
assert.Equal(t, aiobservabilitytypes.RoleUser, span.FormattedInput[0].Role)
require.Len(t, span.FormattedOutput, 1)
assert.Equal(t, aiobservabilitytypes.RoleAssistant, span.FormattedOutput[0].Role)
}

View File

@@ -7,9 +7,10 @@ import (
"sort"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -101,36 +102,36 @@ type WaterfallSpan struct {
// StorableSpan is the ClickHouse scan struct for the v3 waterfall query.
type StorableSpan struct {
StartTime time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
SpanID string `ch:"span_id"`
HasError bool `ch:"has_error"`
Kind int8 `ch:"kind"`
ServiceName string `ch:"resource_string_service$$name"`
Name string `ch:"name"`
AttributesString map[string]string `ch:"attributes_string"`
AttributesNumber map[string]float64 `ch:"attributes_number"`
AttributesBool map[string]bool `ch:"attributes_bool"`
AttributesJSON telemetrystoretypes.JSONValue `ch:"attributes"`
ResourcesString map[string]string `ch:"resources_string"`
Events []string `ch:"events"`
StatusMessage string `ch:"status_message"`
StatusCodeString string `ch:"status_code_string"`
SpanKind string `ch:"kind_string"`
ParentSpanID string `ch:"parent_span_id"`
Flags uint32 `ch:"flags"`
IsRemote string `ch:"is_remote"`
TraceState string `ch:"trace_state"`
StatusCode int16 `ch:"status_code"`
DBName string `ch:"db_name"`
DBOperation string `ch:"db_operation"`
HTTPMethod string `ch:"http_method"`
HTTPURL string `ch:"http_url"`
HTTPHost string `ch:"http_host"`
ExternalHTTPMethod string `ch:"external_http_method"`
ExternalHTTPURL string `ch:"external_http_url"`
ResponseStatusCode string `ch:"response_status_code"`
References string `ch:"references"`
StartTime time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
SpanID string `ch:"span_id"`
HasError bool `ch:"has_error"`
Kind int8 `ch:"kind"`
ServiceName string `ch:"resource_string_service$$name"`
Name string `ch:"name"`
AttributesString map[string]string `ch:"attributes_string"`
AttributesNumber map[string]float64 `ch:"attributes_number"`
AttributesBool map[string]bool `ch:"attributes_bool"`
AttributesJSON chcol.JSON `ch:"attributes"`
ResourcesString map[string]string `ch:"resources_string"`
Events []string `ch:"events"`
StatusMessage string `ch:"status_message"`
StatusCodeString string `ch:"status_code_string"`
SpanKind string `ch:"kind_string"`
ParentSpanID string `ch:"parent_span_id"`
Flags uint32 `ch:"flags"`
IsRemote string `ch:"is_remote"`
TraceState string `ch:"trace_state"`
StatusCode int16 `ch:"status_code"`
DBName string `ch:"db_name"`
DBOperation string `ch:"db_operation"`
HTTPMethod string `ch:"http_method"`
HTTPURL string `ch:"http_url"`
HTTPHost string `ch:"http_host"`
ExternalHTTPMethod string `ch:"external_http_method"`
ExternalHTTPURL string `ch:"external_http_url"`
ResponseStatusCode string `ch:"response_status_code"`
References string `ch:"references"`
}
// MinimalSpan with only the fields needed to build the parent-child tree.
@@ -300,6 +301,23 @@ func (item *StorableSpan) Attributes() map[string]any {
return attributes
}
// FlatJSONAttributes returns the JSON attributes column keyed by full dotted path, dropping the null
// paths ClickHouse materializes for a row from its data part's path schema.
func FlatJSONAttributes(attributes chcol.JSON) map[string]any {
paths := attributes.ValuesByPath()
out := make(map[string]any, len(paths))
for path, value := range paths {
if value == nil {
continue
}
if variant, ok := value.(chcol.Variant); ok && variant.Nil() {
continue
}
out[path] = value
}
return out
}
func (item *StorableSpan) UnmarshalledEvents() []Event {
events := make([]Event, 0, len(item.Events))
for _, eventStr := range item.Events {
@@ -324,7 +342,7 @@ func (item *StorableSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
resources := make(map[string]string)
maps.Copy(resources, item.ResourcesString)
formatted := aiobservabilitytypes.FormatMessages(item.AttributesJSON)
formatted := aiobservabilitytypes.FormatMessages(FlatJSONAttributes(item.AttributesJSON))
return &WaterfallSpan{
Attributes: item.Attributes(),
DBName: item.DBName,

View File

@@ -1,55 +0,0 @@
package telemetrystoretypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/bytedance/sonic"
)
var ErrCodeUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
// JSONValue is the scan target for a ClickHouse JSON column: the connection sets
// output_format_native_write_json_as_string, so the column arrives as a raw document rather than
// the chcol.JSON the driver reports as its scan type.
type JSONValue map[string]any
// Scan decodes into a fresh map every time: a scan target is reused across rows, and unmarshalling
// into the map already there would both keep its keys and hand every row the same map.
func (v *JSONValue) Scan(src any) error {
var raw []byte
switch value := src.(type) {
case nil:
*v = nil
return nil
case string:
raw = []byte(value)
case []byte:
raw = value
default:
return errors.NewInternalf(ErrCodeUnmarshalJSONColumn, "cannot decode %T as a JSON column", src)
}
decoded := JSONValue{}
if err := sonic.Unmarshal(raw, &decoded); err != nil {
return errors.WrapInternalf(err, ErrCodeUnmarshalJSONColumn, "failed to unmarshal JSON column")
}
*v = decoded
return nil
}
// FlattenInto writes v into out under dotted keys, overwriting existing keys.
func (v JSONValue) FlattenInto(prefix string, out map[string]any) {
for k, value := range v {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch child := value.(type) {
case map[string]any:
JSONValue(child).FlattenInto(key, out)
case JSONValue:
child.FlattenInto(key, out)
default:
out[key] = value
}
}
}

View File

@@ -0,0 +1,52 @@
import json
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import RequestType, build_raw_query, get_rows, make_query_request
SERVICE = "scalar-object-body"
def test_scalar_and_object_body_key_collapses(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[Logs]], None],
) -> None:
"""A log body key stored as both a scalar and an object (db.function and db.function.arg) keeps
only one: the body is returned nested, not flattened to dotted keys, so it cannot hold both."""
now = datetime.now(tz=UTC)
logs = [
Logs(
timestamp=now - timedelta(seconds=1),
resources={"service.name": SERVICE},
body_v2=json.dumps({"db.function": "refresh", "db.function.arg": 2}),
body_promoted="",
),
]
export_json_types(logs)
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type=RequestType.RAW,
queries=[build_raw_query("A", "logs", limit=10, filter_expression=f"service.name = '{SERVICE}'")],
)
assert response.status_code == HTTPStatus.OK, response.text
rows = get_rows(response)
assert len(rows) == 1
body = rows[0]["data"]["body"]
# The scalar survives as a string; the sibling object path db.function.arg is not preserved
# under it, so db.function is a plain value rather than an object.
assert body["db"]["function"] == "refresh"
assert isinstance(body["db"]["function"], str)

View File

@@ -0,0 +1,74 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
BuilderQuery,
OrderBy,
RequestType,
TelemetryFieldKey,
get_rows,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces
def test_traces_scalar_and_object_key_both_survive(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
"""A span attribute stored as both a scalar and an object prefix (db.function and
db.function.arg) keeps both dotted keys through the native JSON read. json_only writes only the
JSON column, so no legacy attribute map backfills the keys."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
seed_attribute_evolution("traces", evolution_time)
service = "scalar-object-service"
span_time = evolution_time + timedelta(minutes=5)
insert_traces(
[
Traces(
timestamp=span_time,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="scalar and object",
resources={"service.name": service},
attributes={"db.function": "refresh", "db.function.arg": 2},
attribute_write_mode="json_only",
),
]
)
response = make_query_request(
signoz,
token,
start_ms=int((span_time - timedelta(minutes=1)).timestamp() * 1000),
end_ms=int((span_time + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
BuilderQuery(
signal="traces",
name="A",
limit=10,
filter_expression=f"resource.service.name = '{service}'",
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
).to_dict()
],
)
assert response.status_code == HTTPStatus.OK, response.text
rows = get_rows(response)
assert len(rows) == 1
attributes = rows[0]["data"]["attributes"]
assert attributes["db.function"] == "refresh"
assert isinstance(attributes["db.function"], str)
assert attributes["db.function.arg"] == 2
# the native read returns flat dotted keys, so no Go-side flattening is needed: db is not nested
assert "db" not in attributes

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(