Compare commits

...

11 Commits

Author SHA1 Message Date
nityanandagohain
6f140a7b60 feat(tracedetail): format OpenInference flattened messages 2026-10-15 10:51:24 +05:30
nityanandagohain
8de75d73ec fix: add system message only after checking if it doesn't exists 2026-10-15 10:36:36 +05:30
nityanandagohain
584790e1c6 feat: apply gen_ai.system_instructions and finish_reasons to the thread 2026-10-15 10:03:54 +05:30
nityanandagohain
bb1adce04a fix: update references 2026-10-15 09:45:56 +05:30
nityanandagohain
3de24379d8 fix: cleanup 2026-10-15 00:47:19 +05:30
nityanandagohain
7fa3cc883b fix: keep minimal changes 2026-10-15 00:09:52 +05:30
nityanandagohain
e1c7c0a3ad fix: remove formatting changes 2026-10-14 17:06:29 +05:30
nityanandagohain
15784b2060 Merge remote-tracking branch 'origin/main' into feat/ai-trace-thread-normalize-1 2026-10-14 13:15:18 +05:30
Tushar Vats
da556abac1 feat(logparsingpipeline): order user pipelines around signozlogsnormalizer (#13106)
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
#### Description

- Opamp places user log pipelines around the first
`signozlogsnormalizer` in the logs pipeline, per its `opamp.position`:
the normalizer runs after them by default (`after`), before them with
`before`. Other processors keep their places.
- Without the normalizer, processor ordering is unchanged.
- The `use_json_body`-prepended normalize operator is dropped when the
normalizer already runs ahead of user pipelines (`before`); otherwise
both are kept.
- Removes the `json_body_dual_ingestion` flag: the normalize operator no
longer stashes the original body, and isn't appended after user
pipelines when only that flag is on.

| `use_json_body` | `opamp.position` | Logs pipeline | Normalize
operator |
|---|---|---|---|
| off | no normalizer | memory_limiter → user → custom → batch
(unchanged) | — |
| off | `after` (default) | … → user → normalizer → … | — |
| off | `before` | … → normalizer → user → … | — |
| on | no normalizer | memory_limiter → normalize op → user → custom →
batch | prepended |
| on | `after` (default) | … → normalize op → user → normalizer → … |
prepended (both normalize) |
| on | `before` | … → normalizer → user → … | dropped |

#### Additional Information

- Depends on SigNoz/signoz-otel-collector#953 for the `opamp.position`
config.
- The pipelines preview still prepends the operator under
`use_json_body`, since it can't see per-collector `opamp.position`.
- Configs still setting `json_body_dual_ingestion` keep working; the
unknown flag is skipped with a warning.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-13 15:32:50 +00:00
Tushar Vats
cc8440248b chore(claude): run py-fmt and py-lint after python changes (#13116)
#### Description

- Adds a `pytest.md` rule: run `make py-fmt` and `make py-lint` after
changing `.py` files, matching what integration CI enforces.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-13 13:30:17 +00:00
nityanandagohain
5fa85064e9 feat: add more thread normalizers 2026-10-13 17:37:52 +05:30
31 changed files with 1422 additions and 201 deletions

View File

@@ -17,3 +17,4 @@ For the Python integration suite under `tests/`. Setup, running, and suite layou
- **snake_case parametrize ids.** `ids=["fill_gaps", "fill_zero"]`, not camelCase.
- **Name suite files with the two-digit prefix (`NN_*.py`).** `pyproject.toml` restricts collection to `[0-9][0-9]_*.py` (plus the bootstrap `setup.py` / `run.py`) — a file that doesn't match is silently never collected.
- **Always run pytest from `tests/`.** `--import-mode=importlib` is what allows same-basename files across suites (`querier/01_logs.py` vs `rawexportdata/01_logs.py`), but it disables pytest's implicit `sys.path` injection — `import fixtures` only resolves via `pythonpath = ["."]` from that rootdir.
- **After changing any `.py` file, run `make py-fmt` and `make py-lint`** from the repo root and fix what they report.

View File

@@ -16440,12 +16440,12 @@ paths:
/api/v1/traces/{traceID}/thread:
get:
deprecated: false
description: Returns the spans carrying gen_ai input or output messages, or
a tool execution, in timestamp order. Messages already in the OTel GenAI shape
are decoded into formatted_input and formatted_output, with formatter naming
the converter and formatter_warnings what it could not resolve. Pass nextCursor
as after or prevCursor as before to page, or spanId to open the page around
a span.
description: Returns the gen_ai spans of a trace, model calls, tool calls and
agent spans, in timestamp order. The messages a span carries are decoded into
formatted_input and formatted_output, with formatter naming the converter
and formatter_warnings what it could not resolve; a span without messages
has both empty. Pass nextCursor as after or prevCursor as before to page,
or spanId to open the page around a span.
operationId: GetTraceThread
parameters:
- description: Page size, at most 100. 0 means 20.

View File

@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
};
/**
* Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
* @summary Get thread view for a trace
*/
export const getTraceThread = (

View File

@@ -90,7 +90,7 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
ID: "GetTraceThread",
Tags: []string{"tracedetail"},
Summary: "Get thread view for a trace",
Description: "Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
Description: "Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
RequestQuery: new(spantypes.GetTraceThreadParams),
Response: new(spantypes.GettableTraceThread),
ResponseContentType: "application/json",

View File

@@ -9,7 +9,6 @@ var (
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureJSONBodyDualIngestion = featuretypes.MustNewName("json_body_dual_ingestion")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
FeatureUseTraceAttributesJSON = featuretypes.MustNewName("use_trace_attributes_json")
@@ -65,14 +64,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureJSONBodyDualIngestion,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether the collector's normalize operator keeps the original log body so it is ingested into both the legacy body and the JSON body columns",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableMetricsReduction,
Kind: featuretypes.KindBoolean,

View File

@@ -297,11 +297,11 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
sb.SelectMore("attributes")
}
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAIThreadKeys)
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAISpanGateKeys)
if err != nil {
return nil, err
}
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAIThreadKeys, keys, sb)
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
if err != nil {
return nil, err
}

View File

@@ -220,14 +220,14 @@ func TestGetThreadSpans(t *testing.T) {
name: "FlagOff_ReadsAndFiltersLegacyMaps",
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL,
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages') OR mapContains(attributes_string, 'gen_ai.tool.name'))",
whereSQL: "(mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))",
},
{
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
jsonOn: true,
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL + ", attributes",
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL)",
whereSQL: "(multiIf(attributes.`gen_ai.request.model` IS NOT NULL, attributes.`gen_ai.request.model`::String, mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.agent.name` IS NOT NULL, attributes.`gen_ai.agent.name`::String, mapContains(attributes_string, 'gen_ai.agent.name'), attributes_string['gen_ai.agent.name'], NULL) IS NOT NULL)",
},
}

View File

@@ -2,6 +2,7 @@ package logparsingpipeline
import (
"encoding/json"
"slices"
"strings"
"sync"
@@ -24,10 +25,13 @@ var (
)
const (
memoryLimiterProcessor = "memory_limiter"
memoryLimiterProcessorPrefix = "memory_limiter/"
batchProcessor = "batch"
batchProcessorPrefix = "batch/"
memoryLimiterProcessor = "memory_limiter"
memoryLimiterProcessorPrefix = "memory_limiter/"
batchProcessor = "batch"
batchProcessorPrefix = "batch/"
normalizerProcessor = "signozlogsnormalizer"
normalizerProcessorPrefix = "signozlogsnormalizer/"
normalizerBeforeUserPipelines = "before"
)
// check if the processors already exist
@@ -90,9 +94,14 @@ func getOtelPipelineFromConfig(config map[string]interface{}) (*otelPipeline, er
// 2. signoz user-pipeline processors (in the order given by signozPipelineProcessorNames)
// 3. custom processors (non-signoz, non-memory_limiter, non-batch processors from the current config)
// 4. batch processors (any processor named "batch" or "batch/<id>") and anything after them
//
// If a signozlogsnormalizer processor is present, the order above is skipped: the first one
// runs right after user-pipeline processors, or right before them when its config sets
// `opamp.position: before`, and the other processors keep their places.
func buildCollectorPipelineProcessorsList(
currentCollectorProcessors []string,
signozPipelineProcessorNames []string,
processorConfigs map[string]interface{},
) ([]string, error) {
lockLogsPipelineSpec.Lock()
defer lockLogsPipelineSpec.Unlock()
@@ -103,6 +112,23 @@ func buildCollectorPipelineProcessorsList(
userPipelineSet[p] = struct{}{}
}
if normalizerIdx := slices.IndexFunc(currentCollectorProcessors, isNormalizerProcessor); normalizerIdx >= 0 {
normalizerFirst := opampPosition(processorConfigs, currentCollectorProcessors[normalizerIdx]) == normalizerBeforeUserPipelines
result := make([]string, 0, len(currentCollectorProcessors)+len(signozPipelineProcessorNames))
for idx, p := range currentCollectorProcessors {
if idx == normalizerIdx && !normalizerFirst {
result = append(result, signozPipelineProcessorNames...)
}
if _, inUserPipelines := userPipelineSet[p]; !inUserPipelines && !hasSignozPipelineProcessorPrefix(p) {
result = append(result, p)
}
if idx == normalizerIdx && normalizerFirst {
result = append(result, signozPipelineProcessorNames...)
}
}
return result, nil
}
var memoryLimiters []string
var customProcessors []string
batchIdx := -1
@@ -135,13 +161,51 @@ func buildCollectorPipelineProcessorsList(
return result, nil
}
func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
func isNormalizerProcessor(name string) bool {
return name == normalizerProcessor || strings.HasPrefix(name, normalizerProcessorPrefix)
}
// collectorNormalizesFirst reports whether the logs pipeline of the collector config runs
// signozlogsnormalizer ahead of user pipelines, judged by the first one as in buildCollectorPipelineProcessorsList.
// Malformed configs report false; generateCollectorConfigWithPipelines surfaces their errors.
func collectorNormalizesFirst(collectorConf map[string]interface{}) bool {
p, err := getOtelPipelineFromConfig(collectorConf)
if err != nil || p.Pipelines.Logs == nil {
return false
}
normalizerIdx := slices.IndexFunc(p.Pipelines.Logs.Processors, isNormalizerProcessor)
if normalizerIdx < 0 {
return false
}
processorConfigs, _ := collectorConf["processors"].(map[string]interface{})
return opampPosition(processorConfigs, p.Pipelines.Logs.Processors[normalizerIdx]) == normalizerBeforeUserPipelines
}
func opampPosition(processorConfigs map[string]interface{}, name string) string {
config, _ := processorConfigs[name].(map[string]interface{})
opamp, _ := config["opamp"].(map[string]interface{})
position, _ := opamp["position"].(string)
return position
}
func unmarshalCollectorConfig(config []byte) (map[string]interface{}, error) {
var collectorConf map[string]interface{}
err := yaml.Unmarshal([]byte(config), &collectorConf)
if err != nil {
if err := yaml.Unmarshal(config, &collectorConf); err != nil {
return nil, errors.WrapInvalidInputf(err, CodeCollectorConfigUnmarshalFailed, "could not unmarshal collector config")
}
return collectorConf, nil
}
func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
collectorConf, err := unmarshalCollectorConfig(config)
if err != nil {
return nil, err
}
return generateCollectorConfigWithPipelines(collectorConf, pipelines)
}
// generateCollectorConfigWithPipelines modifies collectorConf in place.
func generateCollectorConfigWithPipelines(collectorConf map[string]any, pipelines []pipelinetypes.GettablePipeline) ([]byte, error) {
signozPipelineProcessors, signozPipelineProcNames, err := PreparePipelineProcessor(pipelines)
if err != nil {
return nil, err
@@ -180,11 +244,11 @@ func GenerateCollectorConfigWithPipelines(config []byte, pipelines []pipelinetyp
return nil, errors.NewInternalf(CodeCollectorConfigLogsPipelineNotFound, "logs pipeline doesn't exist")
}
updatedProcessorList, _ := buildCollectorPipelineProcessorsList(p.Pipelines.Logs.Processors, signozPipelineProcNames)
updatedProcessorList, _ := buildCollectorPipelineProcessorsList(p.Pipelines.Logs.Processors, signozPipelineProcNames, collectorConf["processors"].(map[string]any))
p.Pipelines.Logs.Processors = updatedProcessorList
// add the new processor to the data ( no checks required as the keys will exists)
collectorConf["service"].(map[string]interface{})["pipelines"].(map[string]interface{})["logs"] = p.Pipelines.Logs
collectorConf["service"].(map[string]any)["pipelines"].(map[string]any)["logs"] = p.Pipelines.Logs
updatedConf, err := yaml.Marshal(collectorConf)
if err != nil {

View File

@@ -5,11 +5,14 @@ import (
"fmt"
"testing"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/SigNoz/signoz/pkg/valuer"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
@@ -206,13 +209,74 @@ var BuildLogsPipelineTestData = []struct {
func TestBuildLogsPipeline(t *testing.T) {
for _, test := range BuildLogsPipelineTestData {
Convey(test.Name, t, func() {
v, err := buildCollectorPipelineProcessorsList(test.fromCollector, test.userPipelines)
v, err := buildCollectorPipelineProcessorsList(test.fromCollector, test.userPipelines, nil)
So(err, ShouldBeNil)
So(v, ShouldResemble, test.finalOutput)
})
}
}
func TestBuildLogsPipelineWithNormalizer(t *testing.T) {
a := constants.LogsPPLPfx + "a"
b := constants.LogsPPLPfx + "b"
before := map[string]any{"opamp": map[string]any{"position": "before"}}
after := map[string]any{"opamp": map[string]any{"position": "after"}}
testCases := []struct {
name string
fromCollector []string
userPipelines []string
configs map[string]any
expected []string
}{
{
name: "ExistingAndStaleUserProcessorsReplaced",
fromCollector: []string{"memory_limiter", a, constants.LogsPPLPfx + "stale", constants.OldLogsPPLPfx + "old", "signozlogsnormalizer", "custom", "batch"},
userPipelines: []string{a, b},
expected: []string{"memory_limiter", a, b, "signozlogsnormalizer", "custom", "batch"},
},
{
name: "NamedNormalizerAfter_CustomProcessorsKeepPlace",
fromCollector: []string{"memory_limiter", "custom_a", "signozlogsnormalizer/x", a, "custom_b", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/x": after},
expected: []string{"memory_limiter", "custom_a", a, "signozlogsnormalizer/x", "custom_b", "batch"},
},
{
name: "FirstNormalizerAfter_SecondIgnored",
fromCollector: []string{"memory_limiter", "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/first": after, "signozlogsnormalizer/second": before},
expected: []string{"memory_limiter", a, "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
},
{
name: "FirstNormalizerBefore_SecondIgnored",
fromCollector: []string{"memory_limiter", "signozlogsnormalizer/first", "signozlogsnormalizer/second", "batch"},
userPipelines: []string{a},
configs: map[string]any{"signozlogsnormalizer/first": before, "signozlogsnormalizer/second": after},
expected: []string{"memory_limiter", "signozlogsnormalizer/first", a, "signozlogsnormalizer/second", "batch"},
},
{
name: "NormalizerAfterBatch",
fromCollector: []string{"memory_limiter", "batch", "signozlogsnormalizer"},
userPipelines: []string{a},
expected: []string{"memory_limiter", "batch", a, "signozlogsnormalizer"},
},
{
name: "NoUserPipelines_StaleRemoved",
fromCollector: []string{"memory_limiter", a, "signozlogsnormalizer", "batch"},
expected: []string{"memory_limiter", "signozlogsnormalizer", "batch"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
processors, err := buildCollectorPipelineProcessorsList(testCase.fromCollector, testCase.userPipelines, testCase.configs)
require.NoError(t, err)
assert.Equal(t, testCase.expected, processors)
})
}
}
func TestPipelineAliasCollisionsDontResultInDuplicateCollectorProcessors(t *testing.T) {
require := require.New(t)
@@ -437,3 +501,61 @@ func TestPipeCharInAliasDoesntBreakCollectorConfig(t *testing.T) {
}, result[0].Attributes_string,
)
}
func TestRecommendAgentConfigWithNormalizerProcessorAndJSONBody(t *testing.T) {
userPipeline := makeTestAddAttributePipeline()
controller := &LogParsingPipelineController{
fl: flaggertest.WithUseJSONBody(t, true),
GetIntegrationPipelines: func(context.Context, string) ([]pipelinetypes.GettablePipeline, error) {
return []pipelinetypes.GettablePipeline{userPipeline}, nil
},
}
normalizeOperator := CollectorConfProcessorName(getNormalizePipeline())
user := CollectorConfProcessorName(userPipeline)
testCases := []struct {
name string
processors string
logs string
expected []any
}{
{
name: "NoNormalizer_OperatorPrepended",
logs: "[memory_limiter, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "batch"},
},
{
name: "NormalizerDefault_OperatorKept",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "signozlogsnormalizer", "batch"},
},
{
name: "NormalizerBefore_OperatorDropped",
processors: "signozlogsnormalizer:\n opamp:\n position: before\n",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", "signozlogsnormalizer", user, "batch"},
},
{
name: "NormalizerAfter_OperatorKept",
processors: "signozlogsnormalizer:\n opamp:\n position: after\n",
logs: "[memory_limiter, signozlogsnormalizer, batch]",
expected: []any{"memory_limiter", normalizeOperator, user, "signozlogsnormalizer", "batch"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
conf := fmt.Sprintf(
"processors:\n memory_limiter: {}\n batch: {}\n %s\nservice:\n pipelines:\n logs:\n receivers: [otlp]\n processors: %s\n exporters: [clickhouselogsexporter]\n",
testCase.processors, testCase.logs,
)
recommended, _, err := controller.RecommendAgentConfig(valuer.GenerateUUID(), []byte(conf), nil)
require.NoError(t, err)
var recommendedConf map[string]any
require.NoError(t, yaml.Unmarshal(recommended, &recommendedConf))
logs := recommendedConf["service"].(map[string]any)["pipelines"].(map[string]any)["logs"].(map[string]any)
assert.Equal(t, testCase.expected, logs["processors"])
})
}
}

View File

@@ -220,26 +220,17 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
return err
}
// withNormalizePipeline places normalize where the read path dictates. Ahead of user pipelines
// when queries run on body_v2 (use_json_body), so operators see the body the explorer shows.
// After them when dual ingestion alone writes body_v2, so operators keep seeing the raw body
// users still query. Absent when neither flag is on.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline) []pipelinetypes.GettablePipeline {
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
dualIngestion := ic.fl.BooleanOrEmpty(ctx, flagger.FeatureJSONBodyDualIngestion, evalCtx)
switch {
case ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, evalCtx):
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline(dualIngestion)}, pipelines...)
case dualIngestion:
return append(slices.Clone(pipelines), getNormalizePipeline(true))
default:
// withNormalizePipeline places normalize ahead of user pipelines when queries run on body_v2
// (use_json_body), so operators see the body the explorer shows. Absent when the flag is off
// or collectorNormalizesFirst.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline, collectorNormalizesFirst bool) []pipelinetypes.GettablePipeline {
if collectorNormalizesFirst || !ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return pipelines
}
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline()}, pipelines...)
}
// stashOriginalBody makes normalize carry the pre-normalization body in an internal attribute
// for the exporter to restore into the legacy body column.
func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline {
func getNormalizePipeline() pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
@@ -258,11 +249,10 @@ func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline
},
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
JSONBodyDualIngestion: stashOriginalBody,
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
},
},
}
@@ -371,11 +361,8 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
}
// The collector gets the same pipeline prepended over opamp; see RecommendAgentConfig.
// Under dual ingestion alone it runs after user operators and only feeds body_v2, which
// the explorer does not show yet, so the preview leaves it out. The original-body stash
// is left off: the preview has no exporter to restore and strip it.
if ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline(false)}, pipelines...)
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline()}, pipelines...)
}
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
@@ -396,14 +383,14 @@ func (pc *LogParsingPipelineController) AgentFeatureType() agentConf.AgentFeatur
// Implements agentConf.AgentFeature interface.
// RecommendAgentConfig generates the collector config to be sent to agents.
// The normalize pipeline (when use_json_body or json_body_dual_ingestion is on) is placed
// The normalize pipeline (when use_json_body is on) is placed
// here, after rawPipelineData is serialized. So it is only present in the config sent to
// the collector and never persisted to the database as part of the user's pipeline list.
//
// NOTE: The configId sent to agents is derived from the pipeline version number
// (e.g. "LogPipelines:5"), not the YAML content. If server-side logic changes
// the generated YAML without bumping the version (e.g. toggling the use_json_body or
// json_body_dual_ingestion flags or updating operator IfExpressions), agents that already
// the generated YAML without bumping the version (e.g. toggling the use_json_body
// flag or updating operator IfExpressions), agents that already
// applied that version will not re-apply the new config. In such cases, users must save a
// new pipeline version via the API to force agents to pick up the change.
func (pc *LogParsingPipelineController) RecommendAgentConfig(
@@ -431,10 +418,15 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", err
}
// normalize is only for sending to the collector, never persisted
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines)
collectorConf, err := unmarshalCollectorConfig(currentConfYaml)
if err != nil {
return nil, "", err
}
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, enrichedPipelines)
// normalize is only for sending to the collector, never persisted
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines, collectorNormalizesFirst(collectorConf))
updatedConf, err := generateCollectorConfigWithPipelines(collectorConf, enrichedPipelines)
if err != nil {
return nil, "", err
}

View File

@@ -17,8 +17,14 @@ 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"
GenAIToolCallResult = "gen_ai.tool.call.result"
)
// Per-span costs the SigNoz LLM pricing processor attaches; SigNoz semconv
@@ -32,12 +38,10 @@ const (
)
// GenAISpanGateKeys mark a span as gen_ai: an LLM call, a tool call, or an
// agent span. A trace belongs to the AI explorer when any span carries one.
// agent span. A trace belongs to the AI explorer when any span carries one,
// and the thread of a trace is these spans.
var GenAISpanGateKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// GenAIThreadKeys mark a span as a thread row: a model call with its messages, or a tool execution.
var GenAIThreadKeys = []string{GenAIInputMessages, GenAIOutputMessages, GenAIToolName}
// GenAISpanFilterExpression renders the gate as a query-builder filter
// expression: each gate key ORed on EXISTS.
func GenAISpanFilterExpression() string {

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,23 +4,32 @@ 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"
FormatterTool = "tool"
FormatterOpenInference = "openinference"
FormatterOpenAIChat = "openai.chat"
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, end_turn, stop_sequence, max_tokens and tool_use Anthropic's, tool-calls the
// Vercel AI SDK's:
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L44782
// https://platform.claude.com/docs/en/api/messages
// 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,
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall, "tool-calls": FinishReasonToolCall, "tool_use": FinishReasonToolCall,
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop,
"max_tokens": FinishReasonLength,
}
@@ -34,36 +43,97 @@ type FormattedMessages struct {
Warnings []string
}
// FormatMessages builds the OTel GenAI view of gen_ai.input.messages and gen_ai.output.messages from the
// formats SDKs write. Each value is a JSON string or a structured value; nil means absent.
func FormatMessages(input, output any) FormattedMessages {
f := &formatting{out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
// 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. OpenInference's flattened messages come first,
// then a tool call and its result, then the message attributes. 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{}}}
switch {
case f.openInference():
case attribute(f.attributes, GenAIToolCallArguments) != nil || attribute(f.attributes, GenAIToolCallResult) != nil:
f.tool()
case attribute(f.attributes, GenAIInputMessages) != nil || attribute(f.attributes, GenAIOutputMessages) != nil:
f.messages()
}
f.prependSystemInstructions()
f.fillFinishReasons()
return f.out
}
type formatting struct {
attributes map[string]any
out FormattedMessages
}
func (f *formatting) warn(format string, args ...any) {
f.out.Warnings = append(f.out.Warnings, fmt.Sprintf(format, args...))
}
// messages converts gen_ai.input.messages and gen_ai.output.messages, each by its own shape.
func (f *formatting) messages() {
var inputLabel, outputLabel string
if input != nil {
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 != nil {
if output := attribute(f.attributes, GenAIOutputMessages); output != nil {
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
}
f.label(inputLabel, outputLabel)
}
// label names the formatter after the input side, or the output side when the input has none, and
// warns when the two sides came from different converters.
func (f *formatting) label(inputLabel, outputLabel string) {
f.out.Formatter = inputLabel
if inputLabel == "" {
f.out.Formatter = outputLabel
} else if outputLabel != "" && outputLabel != inputLabel {
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
}
return f.out
}
type formatting struct {
out FormattedMessages
// 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...)
}
func (f *formatting) warn(format string, args ...any) {
f.out.Warnings = append(f.out.Warnings, fmt.Sprintf(format, args...))
// 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
@@ -97,6 +167,53 @@ 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.
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 current
}
// indexed reads a flattened list, "<key>.<N>.*" keys stored as an object keyed by N, in numeric order.
// A list value is returned as is.
func indexed(doc map[string]any, key string) []map[string]any {
var items []any
switch v := attribute(doc, key).(type) {
case []any:
items = v
case map[string]any:
indexes := make([]int, 0, len(v))
byIndex := make(map[int]any, len(v))
for k, item := range v {
n, err := strconv.Atoi(k)
if err != nil {
continue
}
indexes = append(indexes, n)
byIndex[n] = item
}
sort.Ints(indexes)
for _, n := range indexes {
items = append(items, byIndex[n])
}
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
func firstObject(list []any) (map[string]any, bool) {
if len(list) == 0 {
return nil, false
@@ -109,6 +226,31 @@ func genericMessage(value any) OutputMessage {
return OutputMessage{Parts: Parts{part(GenericPart{"type": FormatterGeneric, "content": stringOf(value)})}}
}
func inputMessages(msgs OutputMessages) InputMessages {
out := make(InputMessages, 0, len(msgs))
for _, m := range msgs {
out = append(out, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
}
return out
}
// foldAssistant merges runs of assistant messages into one and drops messages without parts, for
// SDKs that write one message per output item.
func foldAssistant(msgs OutputMessages) OutputMessages {
out := make(OutputMessages, 0, len(msgs))
for _, msg := range msgs {
if len(out) > 0 && msg.Role == RoleAssistant && out[len(out)-1].Role == RoleAssistant {
out[len(out)-1].Parts = append(out[len(out)-1].Parts, msg.Parts...)
continue
}
if len(msg.Parts) == 0 {
continue
}
out = append(out, msg)
}
return out
}
// decode parses a JSON-encoded attribute value, twice when an SDK encoded the string again.
// Anything that is not JSON is returned as is.
func decode(value any) any {
@@ -155,6 +297,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 == "" {
@@ -171,8 +330,37 @@ 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")
}
// 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 OpenAI content block, 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.
func contentPart(item any) Part {
p, ok := item.(map[string]any)
if !ok {
@@ -183,6 +371,9 @@ func contentPart(item any) Part {
}
switch stringOf(p["type"]) {
case "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"])))

View File

@@ -170,7 +170,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":[]}}]}`,
@@ -193,7 +194,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}`,
formatter: FormatterOpenAIChat,

View File

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

View File

@@ -0,0 +1,341 @@
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}`,
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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."}]`,
},
formatter: FormatterOpenInference,
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 copies 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.",
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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.",
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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==",
},
formatter: FormatterOpenInference,
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.",
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
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}`,
},
formatter: FormatterOpenInference,
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",
},
formatter: FormatterOpenInference,
wantInput: `[{"role": "tool", "name": "multiply", "parts": [{"type": "tool_call_response", "id": "call_62136355", "response": "2001"}]}]`,
wantOutput: `[]`,
},
})
}

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

@@ -161,3 +161,36 @@ 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."}]`},
formatter: FormatterSemconv,
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"}]`,
formatter: FormatterSemconv,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -2,6 +2,8 @@ package aiobservabilitytypes
import (
"encoding/json"
"maps"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -12,6 +14,7 @@ type formatCase struct {
name string
input any
output any
attributes map[string]any // flat dotted keys beside the two message attributes
formatter string
warnings []string
wantInput string
@@ -22,7 +25,9 @@ func runFormatCases(t *testing.T, testCases []formatCase) {
t.Helper()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
formatted := FormatMessages(testCase.input, testCase.output)
attributes := map[string]any{GenAIInputMessages: testCase.input, GenAIOutputMessages: testCase.output}
maps.Copy(attributes, testCase.attributes)
formatted := FormatMessages(document(attributes))
assert.Equal(t, testCase.formatter, formatted.Formatter)
assert.Equal(t, append([]string{}, testCase.warnings...), formatted.Warnings)
@@ -36,6 +41,25 @@ 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{
{
@@ -93,9 +117,121 @@ So, the ball costs 5 cents.`,
wantOutput: `[]`,
},
{
name: "NoAttributes_EmptyListsNoFormatter",
name: "NoMessageAttributes_EmptyListsNoFormatter",
attributes: map[string]any{GenAIRequestModel: "gpt-4o", GenAIToolName: "get_weather"},
wantInput: `[]`,
wantOutput: `[]`,
},
})
}
func TestIndexed(t *testing.T) {
testCases := []struct {
name string
doc map[string]any
want []map[string]any
}{
{
name: "NumericKeys_SortedNumerically",
doc: document(map[string]any{"llm.input_messages.10.message.role": "user", "llm.input_messages.2.message.role": "system", "llm.input_messages.x.message.role": "skipped"}),
want: []map[string]any{{"message": map[string]any{"role": "system"}}, {"message": map[string]any{"role": "user"}}},
},
{
name: "StructuredList_KeptInOrder",
doc: map[string]any{"llm": map[string]any{"input_messages": []any{map[string]any{"message": "b"}, map[string]any{"message": "a"}, "not an object"}}},
want: []map[string]any{{"message": "b"}, {"message": "a"}},
},
{name: "Absent_Empty", doc: map[string]any{}, want: []map[string]any{}},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, indexed(testCase.doc, openInferenceInputMessages))
})
}
}
func TestFormat_SystemInstructions(t *testing.T) {
input := `[{"role":"user","parts":[{"type":"text","content":"hi"}]}]`
runFormatCases(t, []formatCase{
{
// the langchain instrumentation moves system messages out of the history into gen_ai.system_instructions,
// a JSON list of parts:
// https://github.com/traceloop/openllmetry/blob/93429cfb9767e6b51db6cb35f6b70f758117669a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/span_utils.py#L304
name: "ListOfParts_LeadsInput",
input: input,
attributes: map[string]any{GenAISystemInstructions: `[{"type": "text", "content": "You are concise."}]`},
formatter: FormatterSemconv,
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."}]`},
formatter: FormatterSemconv,
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"},
formatter: FormatterSemconv,
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"}},
formatter: FormatterSemconv,
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"}},
formatter: FormatterSemconv,
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"},
formatter: FormatterOpenAIChat,
wantInput: `[]`,
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "tool_call"}]`,
},
})
}

View File

@@ -0,0 +1,72 @@
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:
// 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 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.
func (f *formatting) tool() {
id := optionalString(attribute(f.attributes, GenAIToolCallID))
if raw := attribute(f.attributes, GenAIToolCallArguments); raw != nil {
call := ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(attribute(f.attributes, GenAIToolName)), ID: id, Arguments: toolArguments(decode(raw))}
f.out.Input = InputMessages{{Role: RoleAssistant, Parts: Parts{part(call)}}}
}
if raw := attribute(f.attributes, GenAIToolCallResult); raw != nil {
result := ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: id, Response: toolResult(decode(raw))}
f.out.Output = OutputMessages{{Role: RoleTool, Parts: Parts{part(result)}}}
}
f.out.Formatter = FormatterTool
}
func toolArguments(value any) any {
m, ok := value.(map[string]any)
if !ok {
return value
}
if _, isLangChain := m["input_str"]; isLangChain && hasKeys(m, "inputs", "kwargs") {
if inputs := m["inputs"]; inputs != nil {
return inputs
}
return decode(m["input_str"])
}
if len(m) == 2 && hasKeys(m, "args", "kwargs") {
args, _ := m["args"].([]any)
kwargs, _ := m["kwargs"].(map[string]any)
switch {
case len(args) == 0:
return m["kwargs"]
case len(kwargs) == 0:
return args
}
}
return value
}
// toolResult unwraps the LangChain envelope; the traceloop decorator encodes a tool returning
// nothing as the literal null.
func toolResult(value any) any {
if m, ok := value.(map[string]any); ok && len(m) == 2 && hasKeys(m, "output", "kwargs") {
return decode(m["output"])
}
if value == "null" {
return nil
}
return value
}
func hasKeys(m map[string]any, keys ...string) bool {
for _, key := range keys {
if _, ok := m[key]; !ok {
return false
}
}
return true
}

View File

@@ -0,0 +1,112 @@
package aiobservabilitytypes
import "testing"
// Captured cases are the tool span attributes as the collector stored them for the SigNoz/scripts
// static-telemetry-generator captures, after the span mapper filled the gen_ai.tool.call.* keys.
func TestFormat_Tool(t *testing.T) {
runFormatCases(t, []formatCase{
{
// 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",
GenAIToolCallArguments: `{"input_str": "{'city': 'Bengaluru'}", "tags": [], "metadata": {}, "inputs": {"city": "Bengaluru"}, "kwargs": {"color": "green", "name": null, "tool_call_id": null}}`,
GenAIToolCallResult: `{"output": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}", "kwargs": {"tags": [], "color": "green", "name": "get_weather"}}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 18, "summary": "Clear"}}]}]`,
},
{
// traceloop.entity.input and traceloop.entity.output from the @tool decorator, renamed 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",
GenAIToolCallArguments: `{"args": [], "kwargs": {"city": "Bengaluru"}}`,
GenAIToolCallResult: `"{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": {"city": "Bengaluru", "temp_c": 28, "summary": "Partly cloudy"}}]}]`,
},
{
// traceloop.entity.output of a @tool returning None, json.dumps(None), renamed 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",
GenAIToolCallArguments: `{"args": ["OpenAI", "What's the weather in Paris?"], "kwargs": {}}`,
GenAIToolCallResult: `null`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": ["OpenAI", "What's the weather in Paris?"]}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": null}]}]`,
},
{
name: "TraceloopDecorator_BothArgsAndKwargs_EnvelopeKept",
attributes: map[string]any{
GenAIToolName: "lookup",
GenAIToolCallArguments: `{"args": ["Paris"], "kwargs": {"unit": "c"}}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": {"args": ["Paris"], "kwargs": {"unit": "c"}}}]}]`,
wantOutput: `[]`,
},
{
// input.value and output.value of an OpenInference TOOL span, renamed by the mapper:
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/spec/traces.md
name: "OpenInferenceToolSpan_PlainJSONAndText",
attributes: map[string]any{
GenAIToolName: "get_weather",
GenAIToolCallArguments: `{"city":"Bangalore"}`,
GenAIToolCallResult: `27C, partly cloudy, light breeze from the south.`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "arguments": {"city": "Bangalore"}}]}]`,
wantOutput: `[{"role": "tool", "parts": [{"type": "tool_call_response", "response": "27C, partly cloudy, light breeze from the south."}]}]`,
},
{
// ai.toolCall.{id,name,args,result} of a Vercel ai.toolCall span, renamed by the mapper:
// https://ai-sdk.dev/docs/ai-sdk-core/telemetry
name: "VercelToolCall_IDOnBothParts",
attributes: map[string]any{
GenAIToolName: "get_current_weather",
GenAIToolCallID: "call_l0ycKcmt1uPCdGWR4SkwoLBv",
GenAIToolCallArguments: `{"city":"Bengaluru","unit":"c"}`,
GenAIToolCallResult: `{"city":"Bengaluru","unit":"c","temperature":27,"condition":"partly cloudy"}`,
},
formatter: FormatterTool,
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}},
},
formatter: FormatterTool,
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",
attributes: map[string]any{
GenAIInputMessages: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
GenAIToolName: "lookup",
GenAIToolCallArguments: `{"city": "Paris"}`,
},
formatter: FormatterTool,
wantInput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "lookup", "arguments": {"city": "Paris"}}]}]`,
wantOutput: `[]`,
},
})
}

View File

@@ -122,9 +122,6 @@ type PipelineOperator struct {
EnablePaths bool `json:"enable_paths,omitempty" yaml:"enable_paths,omitempty"`
PathPrefix string `json:"path_prefix,omitempty" yaml:"path_prefix,omitempty"`
// normalize fields, set by the server only
JSONBodyDualIngestion bool `json:"-" yaml:"json_body_dual_ingestion,omitempty"`
// Used in Severity Parsing and JSON Flattening mapping
Mapping map[string][]string `json:"mapping,omitempty" yaml:"mapping,omitempty"`
// severity parser fields

View File

@@ -4,7 +4,6 @@ import (
"encoding/base64"
"encoding/json"
"maps"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
@@ -180,7 +179,9 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
maps.Copy(resources, storable.ResourcesString)
timeUnixNano := uint64(storable.StartTime.UnixNano())
attributes := threadAttributes(storable)
formatted := aiobservabilitytypes.FormatMessages(rawAttribute(storable, aiobservabilitytypes.GenAIInputMessages), rawAttribute(storable, aiobservabilitytypes.GenAIOutputMessages))
// 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)
return &ThreadSpan{
SpanID: storable.SpanID,
TraceID: traceID,
@@ -214,20 +215,3 @@ func threadAttributes(storable *StorableSpan) map[string]any {
storable.AttributesJSON.FlattenInto("", attributes)
return attributes
}
// rawAttribute reads one attribute for decoding from the JSON document, where an object value is
// still whole. The legacy maps hold objects split into one key per field, so spans from before
// the JSON rollout are not decoded. Not handled: a list of JSON strings, which formats as generic,
// and a scalar at a prefix of the path, such as gen_ai.input beside gen_ai.input.messages, which
// ends the walk early.
func rawAttribute(storable *StorableSpan, key string) any {
var current any = map[string]any(storable.AttributesJSON)
for segment := range strings.SplitSeq(key, ".") {
object, ok := current.(map[string]any)
if !ok {
return nil
}
current = object[segment]
}
return current
}

View File

@@ -73,35 +73,3 @@ func TestThreadAttributes(t *testing.T) {
})
}
}
func TestRawAttribute(t *testing.T) {
arguments := map[string]any{"city": "Paris", "days": []any{1.0, 2.0}}
testCases := []struct {
name string
storable StorableSpan
key string
want any
}{
{
name: "JSONColumn_ObjectReturnedWhole",
storable: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"tool": map[string]any{"call": map[string]any{"arguments": arguments}}}}},
key: "gen_ai.tool.call.arguments",
want: arguments,
},
{
name: "JSONColumn_MissingPath_Nil",
storable: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"tool": map[string]any{"name": "get_weather"}}}},
key: "gen_ai.tool.call.arguments",
},
{
name: "LegacyMapsOnly_Nil",
storable: StorableSpan{AttributesString: map[string]string{"gen_ai.output.messages": "sunny", "gen_ai.tool.call.arguments.city": "Paris"}},
key: "gen_ai.output.messages",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, rawAttribute(&testCase.storable, testCase.key))
})
}
}

View File

@@ -22,7 +22,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"

View File

@@ -23,7 +23,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"

View File

@@ -24,7 +24,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"

View File

@@ -30,7 +30,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded firing test alert and no retry. The search stub decides which
# branch runs (create / update / reopen), so the whole issue lifecycle is

View File

@@ -25,7 +25,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"

View File

@@ -24,7 +24,6 @@ from fixtures.notification_channel import (
wait_for_alertmanager_sync,
)
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded firing test alert and no retry: create alert on the JSM Ops
# gateway, then append a timeline note. Default-template events + retry are in

View File

@@ -11,7 +11,7 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.traces import ATTRIBUTE_JSON_ROLLOUT_TIME, TraceIdGenerator, Traces, TracesKind
def test_thread_returns_message_spans_in_order(
def test_thread_returns_gen_ai_spans_in_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
@@ -21,24 +21,56 @@ def test_thread_returns_message_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, third_llm_id, flattened_llm_id = (TraceIdGenerator.span_id() for _ in range(7))
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), trace_id=trace_id, span_id=tool_id, parent_span_id=root_id, name="execute_tool get_weather", resources=resources, attributes={"gen_ai.tool.name": "get_weather", "gen_ai.tool.call.id": "call_1", "gen_ai.tool.call.arguments": json.dumps({"city": "Bangalore"}), "gen_ai.tool.call.result": "sunny"}, attribute_write_mode="json_only"
timestamp=now - timedelta(seconds=6),
trace_id=trace_id,
span_id=tool_id,
parent_span_id=root_id,
name="execute_tool get_weather",
resources=resources,
attributes={"gen_ai.tool.name": "get_weather", "gen_ai.tool.call.id": "call_1", "gen_ai.tool.call.arguments": json.dumps({"city": "Bangalore"}), "gen_ai.tool.call.result": "sunny"},
attribute_write_mode="json_only",
),
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=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",
),
]
)
@@ -47,10 +79,14 @@ def test_thread_returns_message_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, 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, third_llm_id, flattened_llm_id]
assert "nextCursor" not in thread
first, tool, input_only, output_only = thread["spans"]
first, tool, agent, input_only, output_only, flattened = thread["spans"]
assert agent["attributes"]["gen_ai.agent.name"] == "planner"
assert agent["formatter"] == ""
assert agent["formatted_input"] == []
assert agent["formatted_output"] == []
assert tool["attributes"]["gen_ai.tool.call.arguments"] == json.dumps({"city": "Bangalore"})
assert tool["attributes"]["gen_ai.tool.call.result"] == "sunny"
assert first["time_unix"] == int((now - timedelta(seconds=8)).timestamp() * 1000)
@@ -64,13 +100,12 @@ def test_thread_returns_message_spans_in_order(
tool_call = {"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Bangalore"}}
assert first["formatter"] == "semconv"
assert first["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
assert first["formatted_input"] == [{"role": "system", "parts": [{"type": "text", "content": "Answer with the tool."}]}, {"role": "user", "parts": [{"type": "text", "content": "weather in Bangalore?"}]}]
assert first["formatted_output"] == [{"role": "assistant", "parts": [tool_call], "finish_reason": "tool_call"}]
assert first["formatter_warnings"] == []
# tool spans carry no messages until their converter lands
assert tool["formatter"] == ""
assert tool["formatted_input"] == []
assert tool["formatted_output"] == []
assert tool["formatter"] == "tool"
assert tool["formatted_input"] == [{"role": "assistant", "parts": [tool_call]}]
assert tool["formatted_output"] == [{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "sunny"}]}]
assert input_only["formatter"] == "openai.chat"
assert input_only["formatted_input"] == [{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "sunny"}]}]
assert input_only["formatted_output"] == []
@@ -78,6 +113,10 @@ def test_thread_returns_message_spans_in_order(
assert output_only["formatted_input"] == []
assert output_only["formatted_output"] == [{"role": "assistant", "parts": [{"type": "text", "content": "It is sunny in Bangalore."}]}]
assert output_only["formatter_warnings"] == ["bare assistant text, role assumed"]
assert flattened["formatter"] == "openinference"
assert flattened["attributes"]["llm.input_messages.0.message.content"] == "thanks"
assert flattened["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "thanks"}]}]
assert flattened["formatted_output"] == [{"role": "assistant", "parts": [{"type": "text", "content": "You're welcome."}], "finish_reason": "stop"}]
def test_thread_paginates_with_cursors(
@@ -95,7 +134,7 @@ def test_thread_paginates_with_cursors(
timestamps = [now - timedelta(seconds=6), now - timedelta(seconds=3), now - timedelta(seconds=3)]
insert_traces(
[
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-pages"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-pages"}, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
]
)
@@ -157,7 +196,7 @@ def test_thread_paginates_across_buckets(
span_ids = [TraceIdGenerator.span_id() for _ in timestamps]
insert_traces(
[
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-buckets"}, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
Traces(timestamp=timestamp, trace_id=trace_id, span_id=span_id, name="chat gpt-4o", resources={"service.name": "tracedetail-thread-buckets"}, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
for span_id, timestamp in zip(span_ids, timestamps, strict=True)
]
)
@@ -202,16 +241,25 @@ def test_thread_opens_around_span(
resources = {"service.name": "tracedetail-thread-anchor"}
root_id = TraceIdGenerator.span_id()
llm_ids = [TraceIdGenerator.span_id() for _ in range(5)]
agent_id = TraceIdGenerator.span_id()
# agent span, not a thread row, sits between the third and fourth llm spans
http_id = TraceIdGenerator.span_id()
# http span, not a thread row, sits between the third and fourth llm spans
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=20), duration=timedelta(seconds=19), 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=18 - 3 * i), trace_id=trace_id, span_id=span_id, parent_span_id=root_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])}, attribute_write_mode="json_only")
Traces(
timestamp=now - timedelta(seconds=18 - 3 * i),
trace_id=trace_id,
span_id=span_id,
parent_span_id=root_id,
name="chat gpt-4o",
resources=resources,
attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": span_id}])},
attribute_write_mode="json_only",
)
for i, span_id in enumerate(llm_ids)
),
Traces(timestamp=now - timedelta(seconds=11), 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=11), trace_id=trace_id, span_id=http_id, parent_span_id=root_id, name="GET /weather", resources=resources, attributes={"http.request.method": "GET"}, attribute_write_mode="json_only"),
]
)
@@ -224,7 +272,7 @@ def test_thread_opens_around_span(
assert response.status_code == HTTPStatus.OK, f"{params}: {response.text}"
return response.json()["data"]
# span with messages: included with its neighbours
# thread span: included with its neighbours
around = get_page({"limit": 3, "spanId": llm_ids[2]})
assert [span["span_id"] for span in around["spans"]] == llm_ids[1:4]
assert around["prevCursor"]
@@ -232,11 +280,11 @@ def test_thread_opens_around_span(
assert [span["span_id"] for span in get_page({"limit": 3, "before": around["prevCursor"]})["spans"]] == llm_ids[:1]
assert [span["span_id"] for span in get_page({"limit": 3, "after": around["nextCursor"]})["spans"]] == llm_ids[4:]
# span without messages: only its neighbours
around_agent = get_page({"limit": 2, "spanId": agent_id})
assert [span["span_id"] for span in around_agent["spans"]] == llm_ids[2:4]
assert around_agent["prevCursor"]
assert around_agent["nextCursor"]
# span outside the thread: only its neighbours
around_http = get_page({"limit": 2, "spanId": http_id})
assert [span["span_id"] for span in around_http["spans"]] == llm_ids[2:4]
assert around_http["prevCursor"]
assert around_http["nextCursor"]
# near the start, the short side gives its room to the other
at_start = get_page({"limit": 3, "spanId": llm_ids[0]})
@@ -280,11 +328,11 @@ def test_thread_reads_spans_across_json_rollout(
legacy_id, json_id = TraceIdGenerator.span_id(), TraceIdGenerator.span_id()
insert_traces(
[
Traces(timestamp=rollout - timedelta(minutes=10), trace_id=before_trace_id, span_id=before_ids[0], name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "first"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=8), trace_id=before_trace_id, span_id=TraceIdGenerator.span_id(), name="invoke_agent planner", resources=resources, attributes={"gen_ai.agent.name": "planner"}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=before_trace_id, span_id=before_ids[1], name="chat gpt-4o", resources=resources, attributes={"gen_ai.output.messages": json.dumps([{"role": "assistant", "content": "second"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=straddle_trace_id, span_id=legacy_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "legacy"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout + timedelta(minutes=5), trace_id=straddle_trace_id, span_id=json_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "json"}])}, attribute_write_mode="json_only"),
Traces(timestamp=rollout - timedelta(minutes=10), trace_id=before_trace_id, span_id=before_ids[0], name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": "first"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=8), trace_id=before_trace_id, span_id=TraceIdGenerator.span_id(), name="GET /weather", resources=resources, attributes={"http.request.method": "GET"}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=before_trace_id, span_id=before_ids[1], name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": json.dumps([{"role": "assistant", "content": "second"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout - timedelta(minutes=5), trace_id=straddle_trace_id, span_id=legacy_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": "legacy"}])}, attribute_write_mode="legacy_only"),
Traces(timestamp=rollout + timedelta(minutes=5), trace_id=straddle_trace_id, span_id=json_id, name="chat gpt-4o", resources=resources, attributes={"gen_ai.request.model": "gpt-4o", "gen_ai.input.messages": json.dumps([{"role": "user", "content": "json"}])}, attribute_write_mode="json_only"),
]
)
@@ -315,7 +363,7 @@ def test_thread_reads_spans_across_json_rollout(
assert straddle_spans[1]["formatted_input"] == [{"role": "user", "parts": [{"type": "text", "content": "json"}]}]
def test_thread_without_messages_is_empty(
def test_thread_without_gen_ai_spans_is_empty(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
@@ -324,7 +372,14 @@ def test_thread_without_messages_is_empty(
) -> None:
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
trace_id = TraceIdGenerator.trace_id()
insert_traces([Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=5), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="GET /health", resources={"service.name": "tracedetail-thread-empty"}, attribute_write_mode="json_only")])
resources = {"service.name": "tracedetail-thread-empty"}
# messages without a gate key, as a failed call or an unmapped SDK writes them, are not a row either
insert_traces(
[
Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=5), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="GET /health", resources=resources, attribute_write_mode="json_only"),
Traces(timestamp=datetime.now(tz=UTC) - timedelta(seconds=4), trace_id=trace_id, span_id=TraceIdGenerator.span_id(), name="chat", resources=resources, attributes={"gen_ai.input.messages": json.dumps([{"role": "user", "content": "hi"}])}, attribute_write_mode="json_only"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/traces/{trace_id}/thread"), headers={"Authorization": f"Bearer {token}"}, timeout=10)