Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .release-intents/20260815-otel2-openai-agents-openrouter.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"summary": "Fix OpenTelemetry 2.x span identity, agent configuration, instrumentation provenance, and embedding output for the OpenAI Agents and OpenRouter instrumentations.",
"packages": {
"@respan/instrumentation-openai-agents": "patch",
"@respan/instrumentation-openrouter": "patch"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
import { hrTime, hrTimeDuration } from "@opentelemetry/core";
import { ReadableSpan } from "@opentelemetry/sdk-trace-base";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import {
ATTR_GEN_AI_USAGE_INPUT_TOKENS,
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS,
Expand All @@ -20,7 +22,10 @@ import {
import { RespanLogType, RespanSpanAttributes } from "@respan/respan-sdk";
import type { Span, Trace } from "@openai/agents";

const PACKAGE_VERSION = "1.0.6";
const packageRequire = createRequire(import.meta.url);
const { version: PACKAGE_VERSION } = packageRequire("../package.json") as {
version: string;
};
const GEN_AI_USAGE_INPUT_TOKENS = ATTR_GEN_AI_USAGE_INPUT_TOKENS;
const GEN_AI_USAGE_OUTPUT_TOKENS = ATTR_GEN_AI_USAGE_OUTPUT_TOKENS;
const LLM_USAGE_CACHE_READ_INPUT_TOKENS = "llm.usage.cache_read_input_tokens";
Expand All @@ -29,6 +34,33 @@ const GEN_AI_COMPLETION_ROLE = `${GEN_AI_COMPLETION_PREFIX}.role`;
const GEN_AI_COMPLETION_CONTENT = `${GEN_AI_COMPLETION_PREFIX}.content`;
const GEN_AI_COMPLETION_TOOL_CALLS = `${GEN_AI_COMPLETION_PREFIX}.tool_calls`;

interface SdkTraceContext {
groupId?: string;
metadata?: Record<string, unknown>;
}

const sdkTraceContexts = new Map<string, SdkTraceContext>();
const RESPAN_METADATA_PREFIX = `${RespanSpanAttributes.RESPAN_METADATA}.`;

export function registerSdkTrace(traceObj: Trace): void {
const metadata = toSerializableValue(traceObj.metadata);
sdkTraceContexts.set(traceObj.traceId, {
groupId: traceObj.groupId || undefined,
metadata:
metadata && typeof metadata === "object" && !Array.isArray(metadata)
? metadata as Record<string, unknown>
: undefined,
});
}

export function clearSdkTrace(traceId: string): void {
sdkTraceContexts.delete(traceId);
}

export function clearSdkTraceContexts(): void {
sdkTraceContexts.clear();
}

function safeJson(obj: any): string {
try {
return JSON.stringify(obj, (_key, value) =>
Expand All @@ -39,6 +71,18 @@ function safeJson(obj: any): string {
}
}

function setMetadataAttribute(
attrs: Record<string, any>,
name: string,
value: unknown,
): void {
const serialized = toSerializableValue(value);
if (serialized === undefined) return;

attrs[`${RESPAN_METADATA_PREFIX}${name}`] =
typeof serialized === "object" ? safeJson(serialized) : serialized;
}

function toSerializableValue(value: any): any {
if (value === null || value === undefined) return undefined;
if (
Expand Down Expand Up @@ -643,21 +687,19 @@ function formatChatCompletionOutput(rawResponse: any): string {
}

function hashStringToHexId(s: string, length: number): string {
let hash = 0;
for (let i = 0; i < s.length; i++) {
hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0;
}
const hex = Math.abs(hash).toString(16).padStart(8, "0");
return (hex + hex + hex + hex).slice(0, length);
const hex = createHash("sha256").update(s, "utf8").digest("hex").slice(0, length);
return /^0+$/.test(hex)
? createHash("sha256").update(`respan:${s}`, "utf8").digest("hex").slice(0, length)
: hex;
}

function ensureTraceId(id: string): string {
if (/^[0-9a-f]{32}$/i.test(id)) return id.toLowerCase();
if (/^[0-9a-f]{32}$/i.test(id) && !/^0+$/.test(id)) return id.toLowerCase();
return hashStringToHexId(id, 32);
}

function ensureSpanId(id: string): string {
if (/^[0-9a-f]{16}$/i.test(id)) return id.toLowerCase();
if (/^[0-9a-f]{16}$/i.test(id) && !/^0+$/.test(id)) return id.toLowerCase();
return hashStringToHexId(id, 16);
}

Expand Down Expand Up @@ -716,6 +758,8 @@ function buildReadableSpan(opts: BuildSpanOptions): ReadableSpan {
? { code: SpanStatusCode.ERROR, message: opts.errorMessage ?? "" }
: { code: SpanStatusCode.OK, message: "" };

applySdkTraceContext(opts.attributes, opts.traceId);

return {
name: opts.name,
kind: SpanKind.INTERNAL,
Expand Down Expand Up @@ -748,6 +792,28 @@ function buildReadableSpan(opts: BuildSpanOptions): ReadableSpan {
} satisfies ReadableSpan;
}

function applySdkTraceContext(attrs: Record<string, any>, traceId: string): void {
const traceContext = sdkTraceContexts.get(traceId);
if (!traceContext) return;

if (traceContext.groupId) {
attrs[RespanSpanAttributes.RESPAN_TRACE_GROUP_ID] = traceContext.groupId;
}
const traceMetadata = traceContext.metadata;
if (!traceMetadata) return;
for (const [name, value] of Object.entries(traceMetadata)) {
const key = `${RESPAN_METADATA_PREFIX}${name}`;
if (attrs[key] === undefined) {
setMetadataAttribute(attrs, name, value);
}
}

const customIdentifier = traceMetadata.custom_identifier;
if (typeof customIdentifier === "string" && customIdentifier) {
attrs[RespanSpanAttributes.RESPAN_SPAN_CUSTOM_ID] = customIdentifier;
}
}

function injectSpan(span: ReadableSpan): void {
const tp = trace.getTracerProvider() as any;
const processor =
Expand Down Expand Up @@ -924,6 +990,10 @@ function emitTrace(traceObj: Trace): void {
if (Object.keys(metadata).length > 0) {
attrs[RespanSpanAttributes.RESPAN_METADATA] = safeJson(metadata);
}
const customIdentifier = metadata.custom_identifier;
if (typeof customIdentifier === "string" && customIdentifier) {
attrs[RespanSpanAttributes.RESPAN_SPAN_CUSTOM_ID] = customIdentifier;
}

const span = buildReadableSpan({
name: `${traceName}.workflow`,
Expand All @@ -942,6 +1012,18 @@ function emitAgent(item: Span<any>): void {
const attrs = baseAttrs(name, name, RespanLogType.AGENT);
attrs[SpanAttributes.TRACELOOP_WORKFLOW_NAME] = name;
attrs[RespanSpanAttributes.RESPAN_METADATA_AGENT_NAME] = name;
const agentConfiguration = {
tools: Array.isArray(data.tools) ? data.tools : undefined,
handoffs: Array.isArray(data.handoffs) ? data.handoffs : undefined,
output_type: typeof data.output_type === "string" ? data.output_type : undefined,
};
for (const [name, value] of Object.entries(agentConfiguration)) {
setMetadataAttribute(
attrs,
`openai_agents.agent_configuration.${name}`,
value,
);
}

const span = buildReadableSpan({
name: `${name}.agent`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,24 @@ import {
type Trace,
type Span,
} from "@openai/agents";
import { emitSdkItem } from "./_otel_emitter.js";
import {
clearSdkTrace,
clearSdkTraceContexts,
emitSdkItem,
registerSdkTrace,
} from "./_otel_emitter.js";

class _RespanTracingProcessor implements TracingProcessor {
async onTraceStart(_trace: Trace): Promise<void> {
// no-op
async onTraceStart(traceObj: Trace): Promise<void> {
registerSdkTrace(traceObj);
}

async onTraceEnd(traceObj: Trace): Promise<void> {
emitSdkItem(traceObj);
try {
emitSdkItem(traceObj);
} finally {
clearSdkTrace(traceObj.traceId);
}
}

async onSpanStart(_span: Span<any>): Promise<void> {
Expand Down Expand Up @@ -61,5 +70,6 @@ export class OpenAIAgentsInstrumentor {

deactivate(): void {
this._processor = null;
clearSdkTraceContexts();
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import test from "node:test";

import { trace } from "@opentelemetry/api";
import { RespanSpanAttributes } from "@respan/respan-sdk";
import { SpanAttributes } from "@traceloop/ai-semantic-conventions";

import { emitSdkItem } from "../dist/_otel_emitter.js";
import {
clearSdkTrace,
emitSdkItem,
registerSdkTrace,
} from "../dist/_otel_emitter.js";

const captureState = { spans: [] };
const originalGetTracerProvider = trace.getTracerProvider.bind(trace);
const packageRequire = createRequire(import.meta.url);
const { version: packageVersion } = packageRequire("../package.json");

test.before(() => {
Object.defineProperty(trace, "getTracerProvider", {
Expand Down Expand Up @@ -92,7 +99,7 @@ test("emit trace stores SDK trace metadata on workflow span", () => {
const attrs = span.attributes;

assert.equal(attrs[RespanSpanAttributes.RESPAN_LOG_TYPE], "workflow");
assert.equal(span.instrumentationScope.version, "1.0.6");
assert.equal(span.instrumentationScope.version, packageVersion);
assert.equal(
attrs[SpanAttributes.TRACELOOP_WORKFLOW_NAME],
"openai_agents_gateway_basic.workflow",
Expand Down Expand Up @@ -548,7 +555,7 @@ test("emit response handles modern agents item and content variants", () => {
assertNoOffContractAliases(attrs);
});

test("emit agent omits tool and handoff aliases", () => {
test("emit agent preserves tool, handoff, and output configuration in canonical metadata", () => {
const attrs = emitAndCapture(
makeBaseSpanData({
type: "agent",
Expand All @@ -562,10 +569,90 @@ test("emit agent omits tool and handoff aliases", () => {
assert.equal(attrs[RespanSpanAttributes.RESPAN_LOG_TYPE], "agent");
assert.equal(attrs[SpanAttributes.TRACELOOP_ENTITY_NAME], "Router");
assert.equal(attrs[RespanSpanAttributes.RESPAN_METADATA_AGENT_NAME], "Router");
assert.equal(
attrs["respan.metadata.openai_agents.agent_configuration.tools"],
JSON.stringify(["lookup_docs"]),
);
assert.equal(
attrs["respan.metadata.openai_agents.agent_configuration.handoffs"],
JSON.stringify(["Support"]),
);
assert.equal(
attrs["respan.metadata.openai_agents.agent_configuration.output_type"],
"text",
);
assertNoOffContractAliases(attrs);
assert.equal(attrs["traceloop.span.kind"], undefined);
});

test("noncanonical SDK IDs map to deterministic full-width nonzero OTel IDs", () => {
const seenTraceIds = new Set();
const seenSpanIds = new Set();

for (let index = 0; index < 4096; index += 1) {
const item = {
...makeBaseSpanData({
type: "agent",
name: `Agent ${index}`,
}),
traceId: `trace_test_${index}`,
spanId: `span_test_${index}`,
parentId: `parent_test_${index}`,
};
const span = emitAndCaptureSpan(item);
const first = span.spanContext();

const repeat = emitAndCaptureSpan(item).spanContext();

assert.equal(first.traceId, repeat.traceId);
assert.equal(first.spanId, repeat.spanId);
assert.match(first.traceId, /^(?!0{32}$)[0-9a-f]{32}$/);
assert.match(first.spanId, /^(?!0{16}$)[0-9a-f]{16}$/);
assert.notEqual(first.traceId.slice(0, 8), first.traceId.slice(8, 16));
assert.notEqual(first.spanId.slice(0, 8), first.spanId.slice(8, 16));
seenTraceIds.add(first.traceId);
seenSpanIds.add(first.spanId);
}

assert.equal(seenTraceIds.size, 4096);
assert.equal(seenSpanIds.size, 4096);
});

test("registered trace marker and grouping propagate to every emitted child", () => {
const traceId = "trace_marker_context";
registerSdkTrace({
traceId,
name: "marker-workflow",
groupId: "marker-group",
metadata: {
custom_identifier: "otel2-fix-marker",
run_id: "otel2-fix-marker",
},
});

try {
const attrs = emitAndCapture({
...makeBaseSpanData({ type: "agent", name: "Marker Agent" }),
traceId,
});
assert.equal(
attrs[RespanSpanAttributes.RESPAN_SPAN_CUSTOM_ID],
"otel2-fix-marker",
);
assert.equal(
attrs[RespanSpanAttributes.RESPAN_TRACE_GROUP_ID],
"marker-group",
);
assert.equal(
attrs["respan.metadata.custom_identifier"],
"otel2-fix-marker",
);
assert.equal(attrs["respan.metadata.run_id"], "otel2-fix-marker");
} finally {
clearSdkTrace(traceId);
}
});

test("emit handoff, guardrail, custom, and mcp tools use common contract attrs", () => {
const handoff = emitAndCapture(
makeBaseSpanData({
Expand Down
Loading
Loading