Skip to content

Commit 1668d0e

Browse files
feat: emit errors for deprecated gen_ai.response.text and validate gen_ai.output.messages against a Zod schema (#145)
* feat: emit errors for deprecated `gen_ai.response.text` and validate `gen_ai.output.messages` against a Zod schema * chore: make manual instrumentation pass schema checks by adding finish_reason
1 parent 18229d1 commit 1668d0e

7 files changed

Lines changed: 88 additions & 21 deletions

File tree

package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"prettier": "^3.8.1",
3535
"vhtml": "^2.2.0",
3636
"vite": "^6.0.11",
37-
"vite-plugin-singlefile": "^2.0.5"
37+
"vite-plugin-singlefile": "^2.0.5",
38+
"zod": "^3.25.76"
3839
},
3940
"devDependencies": {
4041
"@types/chai": "^5.2.3",

src/runner/templates/agents/node/manual/template.njk

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ const TOOLS = {{ agent.tools | dump }};
139139
}
140140
const toolDefinitionsJson{{ loop.index }} = JSON.stringify(toolDefinitions{{ loop.index }});
141141

142-
const outputParts{{ loop.index }} = [{ type: "text", text: responseText{{ loop.index }} }, ...outputToolCallParts{{ loop.index }}];
143-
const outputMessages{{ loop.index }} = JSON.stringify([{ role: "assistant", parts: outputParts{{ loop.index }} }]);
142+
const outputParts{{ loop.index }} = [{ type: "text", content: responseText{{ loop.index }} }, ...outputToolCallParts{{ loop.index }}];
143+
const outputMessages{{ loop.index }} = JSON.stringify([{ role: "assistant", parts: outputParts{{ loop.index }}, finish_reason: "stop" }]);
144144

145145
// Agent span (parent)
146146
const agentDesc{{ loop.index }} = `invoke_agent ${AGENT_NAME}`;

src/runner/templates/agents/python/manual/template.njk

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,9 @@ TOOLS = {{ agent.tools | dump }}
136136
tool_definitions_json = json.dumps(tool_definitions)
137137

138138
# Build output messages
139-
output_parts = [{"type": "text", "text": response_text}]
139+
output_parts = [{"type": "text", "content": response_text}]
140140
output_parts.extend(output_tool_call_parts)
141-
output_messages = json.dumps([{"role": "assistant", "parts": output_parts}])
141+
output_messages = json.dumps([{"role": "assistant", "parts": output_parts, "finish_reason": "stop"}])
142142

143143
# Agent span (parent)
144144
agent_desc = f"invoke_agent {AGENT_NAME}"

src/runner/templates/llm/node/manual/template.njk

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ function buildSentryMessages(messages) {
117117

118118
const responseText{{ loop.index }} = "This is a simulated response for turn {{ loop.index }}.";
119119
const outputMessages{{ loop.index }} = JSON.stringify([
120-
{ role: "assistant", parts: [{ type: "text", text: responseText{{ loop.index }} }] },
120+
{ role: "assistant", parts: [{ type: "text", content: responseText{{ loop.index }} }], finish_reason: "stop" },
121121
]);
122122

123123
const desc{{ loop.index }} = `chat ${model{{ loop.index }}}`;

src/runner/templates/llm/python/manual/template.njk

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def build_sentry_messages(messages):
110110

111111
response_text = "This is a simulated response for turn {{ loop.index }}."
112112
output_messages = json.dumps([
113-
{"role": "assistant", "parts": [{"type": "text", "text": response_text}]}
113+
{"role": "assistant", "parts": [{"type": "text", "content": response_text}], "finish_reason": "stop"}
114114
])
115115

116116
description = f"chat {model}"

src/test-cases/checks.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
* collect or report deprecation warnings.
1313
*/
1414

15+
import { z } from "zod";
16+
1517
import { CapturedSpan, ErrorLocation, Check } from "../types.js";
1618
import { CheckError } from "../validator.js";
1719
import {
@@ -152,6 +154,53 @@ function parseInputMessages(
152154
return { messages: result.value as unknown[], attribute: result.usedAttribute! };
153155
}
154156

157+
const OutputMessagePartSchema = z.object({ type: z.string() }).passthrough();
158+
const OutputMessagesSchema = z.array(
159+
z.object({
160+
role: z.string(),
161+
parts: z.array(OutputMessagePartSchema),
162+
name: z.string().nullable().optional(),
163+
finish_reason: z.string(),
164+
}).passthrough(),
165+
);
166+
167+
function formatZodPath(path: Array<string | number>): string {
168+
return path.reduce<string>((formatted, segment) => {
169+
if (typeof segment === "number") {
170+
return `${formatted}[${segment}]`;
171+
}
172+
return formatted ? `${formatted}.${segment}` : segment;
173+
}, "messages");
174+
}
175+
176+
/**
177+
* Validate gen_ai.output.messages against the OTEL semantic convention schema:
178+
* https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-output-messages.json
179+
*
180+
* The schema's GenericPart branch allows provider-specific part payloads, so this
181+
* mirrors the schema envelope instead of requiring stricter fields for each type.
182+
*/
183+
function validateOutputMessagesSchema(
184+
value: unknown,
185+
attribute = "gen_ai.output.messages",
186+
): string[] {
187+
let parsedValue = value;
188+
if (typeof value === "string") {
189+
try {
190+
parsedValue = JSON.parse(value);
191+
} catch {
192+
return [`Invalid JSON in ${attribute}`];
193+
}
194+
}
195+
196+
const result = OutputMessagesSchema.safeParse(parsedValue);
197+
if (result.success) return [];
198+
199+
return result.error.issues.map(
200+
(issue) => `${formatZodPath(issue.path)} ${issue.message}`,
201+
);
202+
}
203+
155204
function getMessageText(message: unknown): string | undefined {
156205
if (typeof message !== "object" || message === null) {
157206
return undefined;
@@ -329,8 +378,9 @@ function assertOnlyLastInputMessage(
329378
* - description equals "<gen_ai.operation.name> <gen_ai.request.model>"
330379
* - gen_ai.operation.name matches AI_CLIENT_OPERATION_NAME_PATTERN
331380
* - gen_ai.request.model matches expected model
332-
* - gen_ai.request.messages exists
333-
* - gen_ai.response.text exists
381+
* - gen_ai.input.messages exists (or deprecated gen_ai.request.messages fallback)
382+
* - gen_ai.output.messages exists and matches the OTEL output messages schema
383+
* - deprecated gen_ai.response.text is not present
334384
* - gen_ai.usage.input_tokens exists
335385
* - gen_ai.usage.output_tokens exists
336386
*
@@ -373,17 +423,23 @@ export const checkChatSpanAttributes: Check = {
373423
locations.push({ spanId: span.span_id, attribute: "gen_ai.input.messages", message: "Missing messages attribute" });
374424
}
375425

376-
const responseResult = getAttributeWithFallback(
377-
span,
378-
"gen_ai.output.messages",
379-
"gen_ai.response.text"
380-
);
381-
382-
if (responseResult.value === undefined) {
383-
const hasToolCalls = !!span.data?.["gen_ai.response.tool_calls"];
384-
if (!hasToolCalls) {
385-
errors.push("Should have gen_ai.output.messages, gen_ai.response.text, or gen_ai.response.tool_calls");
386-
locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: "Missing response attribute" });
426+
if (span.data?.["gen_ai.response.text"] !== undefined) {
427+
const msg = 'Deprecated attribute "gen_ai.response.text" is not allowed; use "gen_ai.output.messages" instead';
428+
errors.push(msg);
429+
locations.push({ spanId: span.span_id, attribute: "gen_ai.response.text", message: msg });
430+
}
431+
432+
const outputMessages = span.data?.["gen_ai.output.messages"];
433+
434+
if (outputMessages === undefined) {
435+
const msg = "Should have gen_ai.output.messages";
436+
errors.push(msg);
437+
locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: "Missing output messages attribute" });
438+
} else {
439+
const schemaErrors = validateOutputMessagesSchema(outputMessages);
440+
for (const schemaError of schemaErrors) {
441+
errors.push(schemaError);
442+
locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: schemaError });
387443
}
388444
}
389445
}

0 commit comments

Comments
 (0)