mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-30 13:00:42 +00:00
Compare commits
2 Commits
issue_579
...
docs/e2e-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11ed0b0c62 | ||
|
|
9f04647e5b |
@@ -150,6 +150,18 @@ Note: no cleanup code (the `dashboards` driver deletes its seed), no raw locator
|
||||
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title first to blur Monaco; `dashboardList.dismissWithEscape()` does this.
|
||||
- **Empty zero-state hides controls.** With no dashboards in the workspace, the list controls and `new-dashboard-cta` are absent. Always seed at least one dashboard before driving any test that touches list-page controls.
|
||||
|
||||
# Timing traps
|
||||
|
||||
Each of these passes alone and flakes under parallel CI load. Handle them inside the driver action.
|
||||
|
||||
- **Wait for the request the action started.** A `waitForResponse` registered after the click, or matching any in-flight request, can resolve on an earlier one. Register `page.waitForRequest(...)` before the action, then await its `response()`.
|
||||
- **Let React commit before a shortcut.** A keyboard shortcut (e.g. Cmd+Enter) pressed right after typing can read the previous state. Wait two animation frames (`requestAnimationFrame` twice in `page.evaluate`) before pressing it.
|
||||
- **Typing before the first load is lost.** Pages that reset inputs when their initial data lands wipe early keystrokes. The `open` action waits for the page's first data request.
|
||||
- **A cache hit fires nothing.** When a page already fetched on an earlier edit, the next Run may be served from the React Query cache. After one Run, assert the page shows the expected result and the last request sent carries the edit. Don't wait for a fresh request that may not come, and don't click Run repeatedly: that hides a first click sending stale values.
|
||||
- **Open autocomplete explicitly.** Suggestions that appear on focus or typing arrive late under load. Trigger them (e.g. Ctrl+Space) and wait for the option.
|
||||
- **antd dropdowns render in a portal.** A Select's options are not inside the Select. Scope to that Select's own open dropdown, and assert the chosen value on `.ant-select-selection-item`.
|
||||
- **Split long scenarios.** A test chaining many reloads or edits nears the timeout under load. Split it.
|
||||
|
||||
# Quality bar
|
||||
|
||||
- Every test runs end-to-end against a fresh stack. If you can't run it green from a fresh `test_setup`, it's not done.
|
||||
|
||||
@@ -28,7 +28,7 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
|
||||
4. **Root-cause.** Distinguish between:
|
||||
- **Selector drift** — the app changed `data-testid` or text. Fix the locator where it lives: in the driver under `tests/e2e/drivers/` when the spec uses one, so every spec sharing it heals at once. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action — inside the driver action when there is one.
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action — inside the driver action when there is one. A test green alone but red with `CI=1` is usually one of the generator's [timing traps](playwright-test-generator.md#timing-traps).
|
||||
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Seed through the driver (its `dispose` cleans up) or via API with `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts and only the golden telemetry; parallel per-test seeds share the tenant, so an exact-count assertion on telemetry is a test bug — assert structure instead.
|
||||
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
|
||||
5. **Fix.** Edit the spec or its driver. Preserve TC-NN titles, the driver / `authedPage` fixtures, cleanup, and serial mode if present. A driver change must keep every other spec importing it green — run them too. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
|
||||
|
||||
@@ -17,7 +17,7 @@ Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` fr
|
||||
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
|
||||
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
|
||||
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
|
||||
| `langfuse.observation.model.name`, `langfuse.observation.input`, `langfuse.observation.output`, `langfuse.observation.type`, `langfuse.session.id` | Langfuse SDK v3 (Python) and v4 (JS), LiteLLM `langfuse_otel` callback |
|
||||
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
|
||||
| `session.id` | OpenInference, Langfuse, Claude Code |
|
||||
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 5,
|
||||
"version": 4,
|
||||
"definition": {
|
||||
"name": "gen_ai.llm",
|
||||
"condition": {
|
||||
@@ -134,12 +134,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "langfuse.observation.type",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "openinference.span.kind",
|
||||
"context": "attribute",
|
||||
@@ -362,12 +356,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "langfuse.observation.input",
|
||||
"context": "attribute",
|
||||
"operation": "move",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.prompt",
|
||||
"context": "attribute",
|
||||
@@ -394,12 +382,6 @@
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "langfuse.observation.output",
|
||||
"context": "attribute",
|
||||
"operation": "move",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.completion",
|
||||
"context": "attribute",
|
||||
|
||||
@@ -121,13 +121,10 @@ func (f *formatting) messages() {
|
||||
|
||||
// OpenRouter's answer object or an Anthropic response beside the OpenAI chat request it answers is one
|
||||
// format, not a mix: an Anthropic request without system and without Anthropic-only blocks reads as an
|
||||
// OpenAI chat request. A side kept as generic already warned, and a side read from OpenInference's
|
||||
// buckets beside the raw payload of the other is the documented fallback.
|
||||
// OpenAI chat request.
|
||||
func (f *formatting) warnMixedFormats(inputLabel, outputLabel string) {
|
||||
switch {
|
||||
case inputLabel == "" || outputLabel == "" || outputLabel == inputLabel:
|
||||
case inputLabel == FormatterGeneric || outputLabel == FormatterGeneric:
|
||||
case inputLabel == FormatterOpenInference || outputLabel == FormatterOpenInference:
|
||||
case inputLabel == FormatterOpenAIChat && (outputLabel == FormatterOpenRouter || outputLabel == FormatterAnthropic):
|
||||
default:
|
||||
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
|
||||
@@ -186,16 +183,11 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
|
||||
return f.semconv(v), FormatterSemconv
|
||||
case isAnthropicMessages(v):
|
||||
return f.anthropicMessages(v), FormatterAnthropic
|
||||
case isResponsesItems(v):
|
||||
return foldAssistant(f.responsesItems(v)), FormatterOpenAIResponses
|
||||
case first["role"] != nil:
|
||||
return f.openAIChatMessages(v), FormatterOpenAIChat
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if inner, ok := superJSON(v); ok {
|
||||
return f.side(decode(inner), role)
|
||||
}
|
||||
switch {
|
||||
case isLangChainRun(v):
|
||||
return f.langChainRun(v), FormatterLangChain
|
||||
@@ -222,9 +214,6 @@ func (f *formatting) side(value any, role Role) (OutputMessages, string) {
|
||||
if messages, ok := v["messages"].([]any); ok {
|
||||
return f.openAIChatMessages(messages), FormatterOpenAIChat
|
||||
}
|
||||
if (v["role"] != nil && hasKeys(v, "content")) || v["tool_calls"] != nil {
|
||||
return OutputMessages{f.openAIChatSingle(v)}, FormatterOpenAIChat
|
||||
}
|
||||
case string:
|
||||
if v == "" {
|
||||
return OutputMessages{}, FormatterText
|
||||
@@ -320,23 +309,6 @@ func foldAssistant(msgs OutputMessages) OutputMessages {
|
||||
return out
|
||||
}
|
||||
|
||||
// superJSON unwraps {json, meta?}, the envelope superjson, the transformer tRPC apps serialise with,
|
||||
// puts around a value; a Langfuse generation records it around the request when the app passes the
|
||||
// serialised form:
|
||||
// https://github.com/flightcontrolhq/superjson/blob/aaa65e36e8e31da740265a56b27c965ca1c7b754/README.md
|
||||
func superJSON(m map[string]any) (any, bool) {
|
||||
inner, ok := m["json"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
for key := range m {
|
||||
if key != "json" && key != "meta" {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return inner, true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, a bare message list, or a
|
||||
// single message.
|
||||
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records, the Langfuse SDK.
|
||||
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, or a bare message list.
|
||||
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records.
|
||||
// Handled: string and block content, tool_calls nested or flattened, tool messages, refusal,
|
||||
// reasoning_content, finish reasons.
|
||||
// Not yet: streaming delta chunks, legacy completions choices[].text, audio output.
|
||||
@@ -89,19 +88,6 @@ func (f *formatting) openAIChatMessage(m map[string]any) OutputMessage {
|
||||
return msg
|
||||
}
|
||||
|
||||
// openAIChatSingle converts a message recorded outside a list: the first choice's message the Langfuse
|
||||
// OpenAI wrapper writes as the output, or the additional_kwargs of a LangChain AIMessage, {tool_calls}
|
||||
// without a role, which the Langfuse LangChain handler writes for a tool-calling turn:
|
||||
// https://github.com/langfuse/langfuse-python/blob/18baf80450dd5f9c1f85916eb2d1b3c72d2052e1/langfuse/openai.py#L446
|
||||
// https://github.com/langfuse/langfuse-python/blob/18baf80450dd5f9c1f85916eb2d1b3c72d2052e1/langfuse/langchain/CallbackHandler.py#L1558
|
||||
func (f *formatting) openAIChatSingle(m map[string]any) OutputMessage {
|
||||
msg := f.openAIChatMessage(m)
|
||||
if msg.Role.IsZero() {
|
||||
msg.Role = RoleAssistant
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// toolCallPart reads {id, function: {name, arguments}}; a gateway may flatten it to {id, name, arguments|args}.
|
||||
func toolCallPart(c map[string]any) Part {
|
||||
call := ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(c["id"]), Name: stringOf(c["name"])}
|
||||
|
||||
@@ -205,30 +205,6 @@ func TestFormat_OpenAIChat(t *testing.T) {
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// Langfuse SDK generation: the history as a list, the first choice's message as the output
|
||||
name: "SingleMessageOutput_AssistantMessage",
|
||||
input: `[{"role": "system", "content": "You are a support agent on a phone call."}, {"role": "user", "content": "Hello?"}]`,
|
||||
output: `{"role": "assistant", "content": "Hi, this is a courtesy call about your order. Do you have a minute?"}`,
|
||||
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "You are a support agent on a phone call."}]}, {"role": "user", "parts": [{"type": "text", "content": "Hello?"}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "Hi, this is a courtesy call about your order. Do you have a minute?"}]}]`,
|
||||
},
|
||||
{
|
||||
// a structured output that happens to carry a role key is not a message
|
||||
name: "ObjectWithRoleKeyButNoContent_KeptAsGeneric",
|
||||
output: `{"role": "admin", "permissions": ["read", "write"]}`,
|
||||
warnings: []string{"message format not recognised, kept as generic"},
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"permissions\":[\"read\",\"write\"],\"role\":\"admin\"}"}]}]`,
|
||||
},
|
||||
{
|
||||
// Langfuse LangChain handler: the additional_kwargs of the AIMessage, without a role, as the output
|
||||
// of a tool-calling turn
|
||||
name: "RolelessToolCallsOutput_AssistantToolCallPart",
|
||||
output: `{"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Oslo\"}"}}], "refusal": null}`,
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_1", "arguments": {"city": "Oslo"}}]}]`,
|
||||
},
|
||||
{
|
||||
name: "ResponseWithScalarChoice_KeptAsGeneric",
|
||||
output: `{"choices":["oops",{"finish_reason":"stop","index":1,"message":{"role":"assistant","content":"hi"}}]}`,
|
||||
|
||||
@@ -2,15 +2,13 @@ package aiobservabilitytypes
|
||||
|
||||
import "strings"
|
||||
|
||||
// Format: OpenAI Responses, a request {input, instructions}, a response {output, status} or a bare list
|
||||
// of items:
|
||||
// Format: OpenAI Responses, a request {input, instructions} or a response {output, status} of items:
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L47597
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L69691
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L53834
|
||||
// https://github.com/openai/openai-openapi/blob/aeb6a041dc56c011ca9f12127ec898f911100aa0/openapi.yaml#L57596
|
||||
// Written by: gateways recording raw Responses bodies, the raw calls OpenInference records for the
|
||||
// Responses API and the OpenAI Agents SDK when its flattened messages are absent, and LiteLLM's
|
||||
// langfuse_otel callback, the function_call items of a tool-calling turn as a bare list.
|
||||
// Written by: gateways recording raw Responses bodies, and the raw calls OpenInference records for the
|
||||
// Responses API and the OpenAI Agents SDK when its flattened messages are absent.
|
||||
// Handled: message items with input_text, output_text, refusal, input_image and input_file content;
|
||||
// function_call and custom_tool_call with their outputs; reasoning summaries; compaction; every other
|
||||
// *_call item as a server tool call and *_output item as its response; the status and incomplete_details
|
||||
@@ -34,19 +32,6 @@ func isResponsesRequest(m map[string]any) bool {
|
||||
return isItem
|
||||
}
|
||||
|
||||
// isResponsesItems looks for an item only the Responses API has, a tool call or its output, a reasoning
|
||||
// or compaction item, all without a role; a list of messages has none.
|
||||
func isResponsesItems(list []any) bool {
|
||||
for _, item := range list {
|
||||
m, _ := item.(map[string]any)
|
||||
typ := stringOf(m["type"])
|
||||
if m["role"] == nil && (typ == "reasoning" || typ == "compaction" || typ == "mcp_approval_response" || strings.HasSuffix(typ, "_call") || strings.HasSuffix(typ, "_output")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isResponsesResponse(m map[string]any) bool {
|
||||
if _, ok := m["output"].([]any); !ok {
|
||||
return false
|
||||
|
||||
@@ -73,23 +73,6 @@ func TestFormat_OpenAIResponses(t *testing.T) {
|
||||
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Be brief."}]}, {"role": "user", "parts": [{"type": "text", "content": "Say hi"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// LiteLLM langfuse_otel: the function_call items of a tool-calling turn as a bare list
|
||||
name: "BareFunctionCallItems_OneAssistantMessage",
|
||||
output: `[{"id": "chatcmpl-1", "name": "set_stage", "call_id": "call_7", "type": "function_call", "arguments": {"fields": {"stage": "consent"}}}]`,
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "set_stage", "id": "call_7", "arguments": {"fields": {"stage": "consent"}}}]}]`,
|
||||
},
|
||||
{
|
||||
name: "BareItemsWithMessage_ToolRound",
|
||||
input: `[{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Weather in Oslo?"}]}, {"type": "function_call", "call_id": "call_9", "name": "get_weather", "arguments": "{\"city\":\"Oslo\"}"}, {"type": "function_call_output", "call_id": "call_9", "output": "4C, cloudy"}]`,
|
||||
wantInput: `[
|
||||
{"role": "user", "parts": [{"type": "text", "content": "Weather in Oslo?"}]},
|
||||
{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_9", "arguments": {"city": "Oslo"}}]},
|
||||
{"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_9", "response": "4C, cloudy"}]}
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// openinference capture, CreateEmbeddings span: an embeddings request also has input and model
|
||||
name: "EmbeddingsRequest_NotResponses_KeptAsGeneric",
|
||||
|
||||
@@ -4,9 +4,8 @@ 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, or whose buckets hold no message, falls back to the raw provider payload the mapper moved
|
||||
// onto gen_ai.*.messages; pipecat streams the reply into output.value alone, LiteLLM's OpenInference mode
|
||||
// leaves the output bucket empty on a tool-calling turn.
|
||||
// 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
|
||||
@@ -29,14 +28,15 @@ func (f *formatting) openInference() bool {
|
||||
return false
|
||||
}
|
||||
inputLabel, outputLabel := FormatterOpenInference, FormatterOpenInference
|
||||
if msgs := openInferenceMessages(input); len(msgs) > 0 {
|
||||
f.out.Input = inputMessages(msgs)
|
||||
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 msgs := openInferenceMessages(output); len(msgs) > 0 {
|
||||
f.out.Output = 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
|
||||
|
||||
@@ -160,30 +160,16 @@ func TestFormat_OpenInference(t *testing.T) {
|
||||
// which the mapper moves onto gen_ai.output.messages
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_attributes.py#L430
|
||||
// https://github.com/Arize-ai/openinference/blob/12f2cc035edfb00b60b656d977f07abfb7a69900/python/instrumentation/openinference-instrumentation-pipecat/src/openinference/instrumentation/pipecat/_observer.py#L628
|
||||
name: "OutputKeysAbsent_RawOutputFormatted",
|
||||
name: "OutputKeysAbsent_RawOutputFormatted_Warns",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.role": "user",
|
||||
"llm.input_messages.0.message.content": "hi",
|
||||
GenAIOutputMessages: "Hello there.",
|
||||
},
|
||||
warnings: []string{"bare assistant text, role assumed"},
|
||||
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."}]}]`,
|
||||
},
|
||||
{
|
||||
// LiteLLM's OpenInference mode leaves the output bucket empty on a tool-calling turn; the calls are
|
||||
// the Langfuse output the mapper moved onto gen_ai.output.messages
|
||||
name: "EmptyOutputBucket_RawOutputFormatted",
|
||||
attributes: map[string]any{
|
||||
"llm.input_messages.0.message.role": "user",
|
||||
"llm.input_messages.0.message.content": "Yes, go ahead.",
|
||||
"llm.output_messages.0.message.role": "assistant",
|
||||
"llm.output_messages.0.message.content": "",
|
||||
GenAIOutputMessages: `[{"id": "chatcmpl-2", "name": "set_stage", "call_id": "call_8", "type": "function_call", "arguments": {"fields": {"stage": "email"}}}]`,
|
||||
},
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "Yes, go ahead."}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "tool_call", "name": "set_stage", "id": "call_8", "arguments": {"fields": {"stage": "email"}}}]}]`,
|
||||
},
|
||||
{
|
||||
// message.role left unset by some instrumentors: tool_call_id, tool_calls or neither decide it,
|
||||
// as the collector-contrib normalizer does
|
||||
|
||||
@@ -122,31 +122,6 @@ So, the ball costs 5 cents.`,
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
// Langfuse SDK generation: the app passes the request through superjson, the output is the parsed
|
||||
// structured output
|
||||
name: "SuperJSONEnvelope_Unwrapped_StructuredOutputGeneric",
|
||||
input: `{"json": {"model": "gpt-4o-mini", "messages": [{"role": "system", "content": "Extract the main article of the page as JSON."}, {"role": "user", "content": "# Council approves new bike lanes"}], "temperature": 0}}`,
|
||||
output: `{"hasArticle": true, "article": {"title": "Council approves new bike lanes"}}`,
|
||||
warnings: []string{"message format not recognised, kept as generic"},
|
||||
wantInput: `[{"role": "system", "parts": [{"type": "text", "content": "Extract the main article of the page as JSON."}]}, {"role": "user", "parts": [{"type": "text", "content": "# Council approves new bike lanes"}]}]`,
|
||||
wantOutput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"article\":{\"title\":\"Council approves new bike lanes\"},\"hasArticle\":true}"}]}]`,
|
||||
},
|
||||
{
|
||||
name: "SuperJSONEnvelopeWithMeta_Unwrapped",
|
||||
input: `{"json": {"messages": [{"role": "user", "content": "What time is it in Oslo?"}]}, "meta": {"values": {"messages.0.at": ["Date"]}}}`,
|
||||
output: `{"json": {"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": "It is 14:05 in Oslo."}}]}, "meta": {"values": {}}}`,
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "What time is it in Oslo?"}]}]`,
|
||||
wantOutput: `[{"role": "assistant", "parts": [{"type": "text", "content": "It is 14:05 in Oslo."}], "finish_reason": "stop"}]`,
|
||||
},
|
||||
{
|
||||
// a json key beside others is a payload of its own, not the envelope
|
||||
name: "JSONKeyBesideOthers_NotUnwrapped_Generic",
|
||||
input: `{"json": {"messages": []}, "schema": "v1"}`,
|
||||
warnings: []string{"message format not recognised, kept as generic"},
|
||||
wantInput: `[{"role": "", "parts": [{"type": "generic", "content": "{\"json\":{\"messages\":[]},\"schema\":\"v1\"}"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
name: "NoMessageAttributes_EmptyLists",
|
||||
attributes: map[string]any{GenAIRequestModel: "gpt-4o", GenAIToolName: "get_weather"},
|
||||
|
||||
Reference in New Issue
Block a user