diff --git a/.release-intents/20260815-otel2-openai-agents-openrouter.json b/.release-intents/20260815-otel2-openai-agents-openrouter.json new file mode 100644 index 00000000..89e20b6d --- /dev/null +++ b/.release-intents/20260815-otel2-openai-agents-openrouter.json @@ -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" + } +} diff --git a/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/src/_otel_emitter.ts b/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/src/_otel_emitter.ts index d584f58d..5197e08a 100644 --- a/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/src/_otel_emitter.ts +++ b/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/src/_otel_emitter.ts @@ -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, @@ -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"; @@ -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; +} + +const sdkTraceContexts = new Map(); +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 + : 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) => @@ -39,6 +71,18 @@ function safeJson(obj: any): string { } } +function setMetadataAttribute( + attrs: Record, + 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 ( @@ -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); } @@ -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, @@ -748,6 +792,28 @@ function buildReadableSpan(opts: BuildSpanOptions): ReadableSpan { } satisfies ReadableSpan; } +function applySdkTraceContext(attrs: Record, 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 = @@ -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`, @@ -942,6 +1012,18 @@ function emitAgent(item: Span): 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`, diff --git a/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/src/index.ts b/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/src/index.ts index 0f3c3d46..1d84dea7 100644 --- a/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/src/index.ts +++ b/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/src/index.ts @@ -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 { - // no-op + async onTraceStart(traceObj: Trace): Promise { + registerSdkTrace(traceObj); } async onTraceEnd(traceObj: Trace): Promise { - emitSdkItem(traceObj); + try { + emitSdkItem(traceObj); + } finally { + clearSdkTrace(traceObj.traceId); + } } async onSpanStart(_span: Span): Promise { @@ -61,5 +70,6 @@ export class OpenAIAgentsInstrumentor { deactivate(): void { this._processor = null; + clearSdkTraceContexts(); } } diff --git a/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/tests/otel_emitter.test.mjs b/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/tests/otel_emitter.test.mjs index 00c6b25e..c06c3c00 100644 --- a/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/tests/otel_emitter.test.mjs +++ b/javascript-sdks/instrumentations/respan-instrumentation-openai-agents/tests/otel_emitter.test.mjs @@ -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", { @@ -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", @@ -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", @@ -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({ diff --git a/javascript-sdks/instrumentations/respan-instrumentation-openrouter/src/index.ts b/javascript-sdks/instrumentations/respan-instrumentation-openrouter/src/index.ts index f1195b47..7654c208 100644 --- a/javascript-sdks/instrumentations/respan-instrumentation-openrouter/src/index.ts +++ b/javascript-sdks/instrumentations/respan-instrumentation-openrouter/src/index.ts @@ -17,7 +17,10 @@ import { buildReadableSpan, injectSpan } from "@respan/tracing"; import { SpanAttributes } from "@traceloop/ai-semantic-conventions"; const INSTRUMENTATION_NAME = "@respan/instrumentation-openrouter"; -const INSTRUMENTATION_VERSION = "0.1.0"; +const packageRequire = createRequire(import.meta.url); +const { version: INSTRUMENTATION_VERSION } = packageRequire("../package.json") as { + version: string; +}; const OPENROUTER_SYSTEM = "openrouter"; const TRACE_METHOD = "ts_tracing"; // OpenTelemetry/Traceloop JS packages used here do not export a cache-read token constant yet. @@ -282,14 +285,14 @@ function buildChatSpan( attachResponseMetadata(attributes, responseRecord); if (error) attachErrorAttributes(attributes, error); - return buildReadableSpan({ + return withInstrumentationScope(buildReadableSpan({ name: "openrouter.chat", startTimeIso: startedAt.toISOString(), endTimeIso: new Date().toISOString(), attributes: prune(attributes), traceId: parent.traceId, parentId: parent.parentId, - }); + })); } function buildEmbeddingSpan( @@ -301,17 +304,14 @@ function buildEmbeddingSpan( ): ReadableSpan { const responseRecord = response as AnyRecord | undefined; const usage = responseRecord?.usage; - const summary = { - id: responseRecord?.id, - model: responseRecord?.model, - object: responseRecord?.object, - data_count: Array.isArray(responseRecord?.data) ? responseRecord.data.length : undefined, - }; + const embeddings = Array.isArray(responseRecord?.data) + ? responseRecord.data.map((item: AnyRecord) => item?.embedding ?? item) + : undefined; const attributes: AnyRecord = { [ATTR_TRACELOOP_ENTITY_NAME]: "openrouter.embeddings", [ATTR_TRACELOOP_ENTITY_PATH]: "openrouter.embeddings.generate", [ATTR_TRACELOOP_ENTITY_INPUT]: safeStringify(request?.input ?? null), - [ATTR_TRACELOOP_ENTITY_OUTPUT]: safeStringify(prune(summary)), + [ATTR_TRACELOOP_ENTITY_OUTPUT]: safeStringify(embeddings ?? null), [ATTR_LLM_REQUEST_TYPE]: "embedding", [RespanSpanAttributes.RESPAN_LOG_METHOD]: TRACE_METHOD, [RespanSpanAttributes.RESPAN_LOG_TYPE]: RespanLogType.EMBEDDING, @@ -322,14 +322,21 @@ function buildEmbeddingSpan( attachUsageAttributes(attributes, usage); if (error) attachErrorAttributes(attributes, error); - return buildReadableSpan({ + return withInstrumentationScope(buildReadableSpan({ name: "openrouter.embeddings", startTimeIso: startedAt.toISOString(), endTimeIso: new Date().toISOString(), attributes: prune(attributes), traceId: parent.traceId, parentId: parent.parentId, - }); + })); +} + +function withInstrumentationScope(span: ReadableSpan): ReadableSpan { + return { + ...span, + instrumentationScope, + }; } function resolveChatRequest(request: AnyRecord = {}): AnyRecord { diff --git a/javascript-sdks/instrumentations/respan-instrumentation-openrouter/tests/openrouter_instrumentor.test.mjs b/javascript-sdks/instrumentations/respan-instrumentation-openrouter/tests/openrouter_instrumentor.test.mjs index 7213305d..8a4c95cc 100644 --- a/javascript-sdks/instrumentations/respan-instrumentation-openrouter/tests/openrouter_instrumentor.test.mjs +++ b/javascript-sdks/instrumentations/respan-instrumentation-openrouter/tests/openrouter_instrumentor.test.mjs @@ -4,10 +4,12 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import test from "node:test"; import assert from "node:assert/strict"; +import { trace } from "@opentelemetry/api"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const sourcePath = resolve(root, "src/index.ts"); const require = createRequire(import.meta.url); +const { version: packageVersion } = require("../package.json"); async function loadSdkPrototypes() { const [chatModule, embeddingsModule] = await Promise.all([ @@ -124,6 +126,77 @@ test("waits for patch work to settle before cleaning up a failed activation", as } }); +test("chat and embedding spans use the OpenRouter package scope", async () => { + const mod = await import(pathToFileURL(resolve(root, "dist/index.js"))); + const prototypes = await loadSdkPrototypes(); + const originalSend = prototypes.chat.send; + const originalGenerate = prototypes.embeddings.generate; + const originalGetTracerProvider = trace.getTracerProvider.bind(trace); + const spans = []; + const instrumentor = new mod.OpenRouterInstrumentor(); + + prototypes.chat.send = async () => ({ + model: "openai/gpt-test", + choices: [{ message: { role: "assistant", content: "hello" } }], + usage: { promptTokens: 2, completionTokens: 1, totalTokens: 3 }, + }); + prototypes.embeddings.generate = async () => ({ + model: "openai/embed-test", + data: [{ embedding: [0.1, 0.2], index: 0 }], + usage: { promptTokens: 2, totalTokens: 2 }, + }); + Object.defineProperty(trace, "getTracerProvider", { + configurable: true, + writable: true, + value() { + return { + activeSpanProcessor: { + onEnd(span) { + spans.push(span); + }, + }, + }; + }, + }); + + try { + await instrumentor.activate(); + await prototypes.chat.send({ + chatRequest: { + model: "openai/gpt-test", + messages: [{ role: "user", content: "hello" }], + }, + }); + await prototypes.embeddings.generate({ + requestBody: { + model: "openai/embed-test", + input: ["hello"], + }, + }); + + assert.equal(spans.length, 2); + for (const span of spans) { + assert.deepEqual(span.instrumentationScope, { + name: "@respan/instrumentation-openrouter", + version: packageVersion, + }); + } + assert.deepEqual( + JSON.parse(spans[1].attributes["traceloop.entity.output"]), + [[0.1, 0.2]], + ); + } finally { + await instrumentor.deactivate(); + prototypes.chat.send = originalSend; + prototypes.embeddings.generate = originalGenerate; + Object.defineProperty(trace, "getTracerProvider", { + configurable: true, + writable: true, + value: originalGetTracerProvider, + }); + } +}); + test("uses canonical constants and avoids banned package-owned aliases", async () => { const source = await readFile(sourcePath, "utf8"); assert.match(source, /@opentelemetry\/semantic-conventions\/incubating/);