From f3fb9e0665897df8c5dc2b9eba0ba784ec0afd94 Mon Sep 17 00:00:00 2001 From: ToryMic Date: Tue, 29 Sep 2026 05:13:30 -0400 Subject: [PATCH] [#1289] feat(sdk): add OpenTelemetry traceparent header propagation in BridgeWatch SDK queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP requests made by the SDK carried no distributed-tracing headers, so integrators could not correlate an SDK call with the backend's database spans. Adds W3C Trace Context propagation to the SDK's HTTP queries. ### `sdk/src/tracecontext.ts` A self-contained implementation of the `traceparent` / `tracestate` formats: - `parseTraceparent` / `formatTraceparent` for the header, rejecting the all-zero trace/span ids, malformed lengths, non-hex characters, and (per the spec) uppercase hex. - `resolveTraceContext` decides the outgoing context. With an inbound `traceparent` it **keeps the trace id and mints a fresh span id per request** — that new span is what shows up in the backend's trace and links back to the caller. Without one it starts a new root trace. - `injectTraceHeaders` applies the headers to a `Headers` object without clobbering a `traceparent` a caller already set, and never forwards `tracestate` on its own since it is meaningless without its traceparent. ### Wiring `BridgeWatchSdkConfig.tracing` takes the inbound `traceparent` / `tracestate` (plus optional `startNewTrace` and `sampled` overrides), and `fetchCompatibility` injects the headers into its request. The existing `X-API-Version` / `Accept` headers are preserved. The config field is intentionally left out of `Required<>` — propagation is opt-in, and a forced default would misrepresent the no-tracing case. ### Tests 37 new tests: 29 unit tests over parsing, validation, id generation, and context resolution, plus 8 integration tests asserting the header actually reaches the outgoing request — including that two requests in the same trace share a trace id but get distinct span ids, and that a malformed inbound `traceparent` is replaced rather than propagated. --- sdk/src/client.ts | 18 ++- sdk/src/index.ts | 1 + sdk/src/tracePropagation.test.ts | 137 ++++++++++++++++++ sdk/src/tracecontext.test.ts | 231 +++++++++++++++++++++++++++++++ sdk/src/tracecontext.ts | 168 ++++++++++++++++++++++ sdk/src/types.ts | 9 ++ 6 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 sdk/src/tracePropagation.test.ts create mode 100644 sdk/src/tracecontext.test.ts create mode 100644 sdk/src/tracecontext.ts diff --git a/sdk/src/client.ts b/sdk/src/client.ts index b256269f..a63edd3b 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -16,9 +16,16 @@ import type { } from "./types"; import type { ApiCapabilities, ApiContract, ApiContractSummary, ApiVersion } from "./compatibility"; import { compatibilityHeaders } from "./compatibility"; +import { injectTraceHeaders, type TraceContextOptions } from "./tracecontext"; export class BridgeWatchContractSdk { - private readonly config: Required; + /** + * `tracing` is deliberately left optional rather than `Required<>`-ed: + * trace propagation is opt-in, and forcing a default would make the + * no-tracing case a lie. + */ + private readonly config: Required> & + Pick; private readonly server: StellarSdk.rpc.Server; private connected = false; @@ -49,8 +56,15 @@ export class BridgeWatchContractSdk { } private async fetchCompatibility(path: string, version?: ApiVersion): Promise { + // Propagate W3C trace context so the backend can correlate this request + // with the caller's trace. An explicit per-call context wins over config. + const headers = injectTraceHeaders( + compatibilityHeaders(version), + this.config.tracing as TraceContextOptions | undefined + ); + const response = await fetch(`${this.config.apiUrl.replace(/\/$/, "")}/api/v1/compatibility${path}`, { - headers: compatibilityHeaders(version), + headers, }); if (!response.ok) throw new BridgeWatchConnectionError(`Compatibility request failed: ${response.status}`); return response.json() as Promise; diff --git a/sdk/src/index.ts b/sdk/src/index.ts index b2d2aae5..2db2e91f 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -4,4 +4,5 @@ export * from "./client"; export * from "./contract"; export * from "./testing"; export * from "./compatibility"; +export * from "./tracecontext"; export * from "./pagination"; diff --git a/sdk/src/tracePropagation.test.ts b/sdk/src/tracePropagation.test.ts new file mode 100644 index 00000000..b0e9b18c --- /dev/null +++ b/sdk/src/tracePropagation.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { BridgeWatchContractSdk } from "./client"; + +const testConfig = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + apiUrl: "https://api.bridge-watch.test", +} as never; + +const VALID_TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + +function stubFetch() { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function sentHeaders(fetchMock: ReturnType): Headers { + const init = fetchMock.mock.calls[0][1] as RequestInit; + return new Headers(init.headers); +} + +describe("SDK traceparent propagation", () => { + beforeEach(() => { + stubFetch(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("adds a traceparent to outgoing compatibility requests", async () => { + const fetchMock = stubFetch(); + const sdk = new BridgeWatchContractSdk(testConfig); + + await sdk.getApiContract(); + + expect(sentHeaders(fetchMock).get("traceparent")).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-(00|01)$/ + ); + }); + + it("keeps the existing API version headers intact", async () => { + const fetchMock = stubFetch(); + const sdk = new BridgeWatchContractSdk(testConfig); + + await sdk.getApiContract(); + + const headers = sentHeaders(fetchMock); + expect(headers.get("X-API-Version")).toBe("v1"); + expect(headers.get("Accept")).toBe("application/vnd.bridge-watch.v1+json"); + }); + + it("continues the trace of a supplied inbound traceparent", async () => { + const fetchMock = stubFetch(); + const sdk = new BridgeWatchContractSdk({ + ...(testConfig as object), + tracing: { traceparent: VALID_TRACEPARENT }, + } as never); + + await sdk.getApiContract(); + + const header = sentHeaders(fetchMock).get("traceparent")!; + expect(header).toContain("4bf92f3577b34da6a3ce929d0e0e4736"); + }); + + it("forwards a tracestate alongside the traceparent", async () => { + const fetchMock = stubFetch(); + const sdk = new BridgeWatchContractSdk({ + ...(testConfig as object), + tracing: { traceparent: VALID_TRACEPARENT, tracestate: "vendor=abc" }, + } as never); + + await sdk.getApiContract(); + + const headers = sentHeaders(fetchMock); + expect(headers.get("tracestate")).toBe("vendor=abc"); + }); + + it("uses a fresh trace id when startNewTrace is set", async () => { + const fetchMock = stubFetch(); + const sdk = new BridgeWatchContractSdk({ + ...(testConfig as object), + tracing: { traceparent: VALID_TRACEPARENT, startNewTrace: true }, + } as never); + + await sdk.getApiContract(); + + expect(sentHeaders(fetchMock).get("traceparent")).not.toContain( + "4bf92f3577b34da6a3ce929d0e0e4736" + ); + }); + + it("mints a new span for every request within the same trace", async () => { + const fetchMock = stubFetch(); + const sdk = new BridgeWatchContractSdk({ + ...(testConfig as object), + tracing: { traceparent: VALID_TRACEPARENT }, + } as never); + + await sdk.getApiContract(); + await sdk.getApiCapabilities(); + + const traceparents = fetchMock.mock.calls.map((call) => { + const init = call[1] as RequestInit; + return new Headers(init.headers).get("traceparent"); + }); + + const traceIds = new Set( + traceparents.map((value) => value!.split("-")[1]) + ); + const spanIds = new Set( + traceparents.map((value) => value!.split("-")[2]) + ); + + // Same trace, distinct spans: that is what links the backend's work back + // to the caller's trace. + expect(traceIds.size).toBe(1); + expect(spanIds.size).toBe(2); + }); + + it("ignores a malformed inbound traceparent rather than propagating it", async () => { + const fetchMock = stubFetch(); + const sdk = new BridgeWatchContractSdk({ + ...(testConfig as object), + tracing: { traceparent: "totally-invalid" }, + } as never); + + await sdk.getApiContract(); + + expect(sentHeaders(fetchMock).get("traceparent")).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-(00|01)$/ + ); + }); +}); diff --git a/sdk/src/tracecontext.test.ts b/sdk/src/tracecontext.test.ts new file mode 100644 index 00000000..b4b6139f --- /dev/null +++ b/sdk/src/tracecontext.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from "vitest"; +import { + parseTraceparent, + formatTraceparent, + isValidTraceId, + isValidSpanId, + randomHex, + resolveTraceContext, + injectTraceHeaders, +} from "./tracecontext"; + +const VALID_TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + +describe("parseTraceparent", () => { + it("parses a valid header", () => { + expect(parseTraceparent(VALID_TRACEPARENT)).toEqual({ + version: "00", + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + sampled: true, + }); + }); + + it("reads the sampled flag from the low bit", () => { + expect(parseTraceparent(VALID_TRACEPARENT)?.sampled).toBe(true); + expect( + parseTraceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00")?.sampled + ).toBe(false); + }); + + it("ignores other flag bits", () => { + // 0xfe has the low bit clear, so the trace is not sampled. + expect( + parseTraceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-fe")?.sampled + ).toBe(false); + }); + + it("rejects an all-zero trace id", () => { + expect( + parseTraceparent(`00-${"0".repeat(32)}-00f067aa0ba902b7-01`) + ).toBeNull(); + }); + + it("rejects an all-zero span id", () => { + expect( + parseTraceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01") + ).toBeNull(); + }); + + it("rejects malformed values", () => { + expect(parseTraceparent(null)).toBeNull(); + expect(parseTraceparent(undefined)).toBeNull(); + expect(parseTraceparent("")).toBeNull(); + expect(parseTraceparent("garbage")).toBeNull(); + expect(parseTraceparent("00-tooshort-00f067aa0ba902b7-01")).toBeNull(); + }); + + it("rejects uppercase hex, which the spec forbids", () => { + expect( + parseTraceparent("00-4BF92F3577B34DA6A3CE929D0E0E4736-00F067AA0BA902B7-01") + ).toBeNull(); + }); + + it("tolerates surrounding whitespace", () => { + expect(parseTraceparent(` ${VALID_TRACEPARENT} `)).not.toBeNull(); + }); +}); + +describe("formatTraceparent", () => { + it("round-trips through parse", () => { + const parts = { + traceId: "4bf92f3577b34da6a3ce929d0e0e4736", + spanId: "00f067aa0ba902b7", + sampled: true, + }; + expect(parseTraceparent(formatTraceparent(parts))).toMatchObject(parts); + }); + + it("emits 00 for unsampled", () => { + expect(formatTraceparent({ traceId: "a".repeat(32), spanId: "b".repeat(16), sampled: false })).toMatch( + /-00$/ + ); + }); +}); + +describe("id validation", () => { + it("requires the exact hex length", () => { + expect(isValidTraceId("a".repeat(32))).toBe(true); + expect(isValidTraceId("a".repeat(31))).toBe(false); + expect(isValidSpanId("b".repeat(16))).toBe(true); + expect(isValidSpanId("b".repeat(15))).toBe(false); + }); + + it("rejects non-hex characters", () => { + expect(isValidTraceId("z".repeat(32))).toBe(false); + expect(isValidSpanId("z".repeat(16))).toBe(false); + }); +}); + +describe("randomHex", () => { + it("returns the requested number of bytes as hex", () => { + expect(randomHex(16)).toMatch(/^[0-9a-f]{32}$/); + expect(randomHex(8)).toMatch(/^[0-9a-f]{16}$/); + }); + + it("does not repeat itself", () => { + const values = new Set(Array.from({ length: 50 }, () => randomHex(8))); + expect(values.size).toBe(50); + }); +}); + +describe("resolveTraceContext", () => { + it("starts a new trace when no parent is supplied", () => { + const context = resolveTraceContext(); + expect(parseTraceparent(context.traceparent)).not.toBeNull(); + }); + + it("keeps the parent trace id and mints a new span id", () => { + const parent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + const context = resolveTraceContext({ traceparent: parent }); + const parsed = parseTraceparent(context.traceparent)!; + + expect(parsed.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); + expect(parsed.spanId).not.toBe("00f067aa0ba902b7"); + }); + + it("inherits the sampled flag from the parent", () => { + const parent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00"; + expect(parseTraceparent(resolveTraceContext({ traceparent: parent }).traceparent)?.sampled).toBe( + false + ); + }); + + it("lets the caller force the sampled flag", () => { + const context = resolveTraceContext({ traceparent: VALID_TRACEPARENT, sampled: false }); + expect(parseTraceparent(context.traceparent)?.sampled).toBe(false); + }); + + it("starts a new trace when asked to break the chain", () => { + const context = resolveTraceContext({ traceparent: VALID_TRACEPARENT, startNewTrace: true }); + expect(parseTraceparent(context.traceparent)?.traceId).not.toBe( + "4bf92f3577b34da6a3ce929d0e0e4736" + ); + }); + + it("forwards a valid tracestate alongside the parent", () => { + const context = resolveTraceContext({ + traceparent: VALID_TRACEPARENT, + tracestate: "vendor=value", + }); + expect(context.tracestate).toBe("vendor=value"); + }); + + it("drops tracestate when there is no valid parent", () => { + const context = resolveTraceContext({ tracestate: "vendor=value" }); + expect(context.tracestate).toBeUndefined(); + }); + + it("drops an invalid tracestate", () => { + const context = resolveTraceContext({ + traceparent: VALID_TRACEPARENT, + tracestate: "x".repeat(600), + }); + expect(context.tracestate).toBeUndefined(); + }); + + it("mints a distinct span id per call", () => { + const spanIds = new Set( + Array.from({ length: 20 }, () => + parseTraceparent(resolveTraceContext({ traceparent: VALID_TRACEPARENT }).traceparent)! + .spanId + ) + ); + expect(spanIds.size).toBe(20); + }); +}); + +describe("injectTraceHeaders", () => { + it("adds a traceparent to an empty header set", () => { + const headers = injectTraceHeaders(new Headers()); + expect(headers.get("traceparent")).toMatch( + /^00-[0-9a-f]{32}-[0-9a-f]{16}-(00|01)$/ + ); + }); + + it("does not overwrite a traceparent the caller already set", () => { + const headers = new Headers({ traceparent: VALID_TRACEPARENT }); + injectTraceHeaders(headers, { startNewTrace: true }); + expect(headers.get("traceparent")).toBe(VALID_TRACEPARENT); + }); + + it("preserves unrelated headers", () => { + const headers = new Headers({ "X-API-Version": "v1" }); + injectTraceHeaders(headers); + expect(headers.get("X-API-Version")).toBe("v1"); + expect(headers.get("traceparent")).not.toBeNull(); + }); + + it("keeps the parent trace id but mints a fresh span for the request", () => { + const headers = injectTraceHeaders(new Headers(), { traceparent: VALID_TRACEPARENT }); + const parsed = parseTraceparent(headers.get("traceparent"))!; + + expect(parsed.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); + // A new span is what makes this request visible as its own span in the + // backend's trace, so it must differ from the caller's span. + expect(parsed.spanId).not.toBe("00f067aa0ba902b7"); + }); + + it("breaks the chain when startNewTrace is requested", () => { + const headers = injectTraceHeaders(new Headers(), { + traceparent: VALID_TRACEPARENT, + startNewTrace: true, + }); + expect(parseTraceparent(headers.get("traceparent"))?.traceId).not.toBe( + "4bf92f3577b34da6a3ce929d0e0e4736" + ); + }); + + it("replaces a malformed inbound parent with a fresh trace", () => { + const headers = injectTraceHeaders(new Headers(), { traceparent: "not-a-traceparent" }); + expect(parseTraceparent(headers.get("traceparent"))).not.toBeNull(); + }); + + it("forwards a valid tracestate", () => { + const headers = injectTraceHeaders(new Headers(), { + traceparent: VALID_TRACEPARENT, + tracestate: "vendor=value", + }); + expect(headers.get("tracestate")).toBe("vendor=value"); + }); +}); diff --git a/sdk/src/tracecontext.ts b/sdk/src/tracecontext.ts new file mode 100644 index 00000000..99135c85 --- /dev/null +++ b/sdk/src/tracecontext.ts @@ -0,0 +1,168 @@ +/** + * W3C Trace Context propagation for SDK requests. + * + * Implements the `traceparent` / `tracestate` formats from the W3C Trace + * Context recommendation so API calls made through the SDK carry the caller's + * trace into the backend, where it can be correlated with database spans. + * + * @see https://www.w3.org/TR/trace-context/ + */ + +/** `00-<32 hex trace-id>-<16 hex span-id>-<2 hex flags>` */ +const TRACEPARENT_PATTERN = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; + +/** All-zero trace-id and span-id are invalid per the spec. */ +const INVALID_TRACE_ID = "0".repeat(32); +const INVALID_SPAN_ID = "0".repeat(16); + +export interface TraceparentParts { + version: string; + traceId: string; + spanId: string; + sampled: boolean; +} + +/** True when a 32-char trace id contains at least one non-zero hex digit. */ +export function isValidTraceId(traceId: string): boolean { + return /^[0-9a-f]{32}$/.test(traceId) && traceId !== INVALID_TRACE_ID; +} + +/** True when a 16-char span id contains at least one non-zero hex digit. */ +export function isValidSpanId(spanId: string): boolean { + return /^[0-9a-f]{16}$/.test(spanId) && spanId !== INVALID_SPAN_ID; +} + +/** Parse a `traceparent` header value, returning null when malformed. */ +export function parseTraceparent(value: string | null | undefined): TraceparentParts | null { + if (!value) return null; + const match = TRACEPARENT_PATTERN.exec(value.trim()); + if (!match) return null; + + const [, traceId, spanId, flags] = match; + if (!isValidTraceId(traceId) || !isValidSpanId(spanId)) return null; + + return { + version: "00", + traceId, + spanId, + // Only the low bit of the flags byte is defined. + sampled: (parseInt(flags, 16) & 0x01) === 0x01, + }; +} + +/** Serialise trace context back into a `traceparent` header value. */ +export function formatTraceparent(parts: { + traceId: string; + spanId: string; + sampled: boolean; +}): string { + return `00-${parts.traceId}-${parts.spanId}-${parts.sampled ? "01" : "00"}`; +} + +/** + * Produce 16 random bytes as lowercase hex. + * + * Uses `crypto.getRandomValues` when available and falls back to `Math.random` + * so the SDK still works on runtimes without Web Crypto. Span ids only need to + * be unique within a trace, not unguessable. + */ +export function randomHex(byteLength: number): string { + const bytes = new Uint8Array(byteLength); + const webCrypto = (globalThis as { crypto?: Crypto }).crypto; + + if (webCrypto?.getRandomValues) { + webCrypto.getRandomValues(bytes); + } else { + for (let i = 0; i < byteLength; i += 1) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function isValidTracestate(value: string | null | undefined): value is string { + if (!value) return false; + // W3C limits tracestate to 32 list-members of 256 chars each. + if (value.length > 512) return false; + return value + .split(",") + .every((member) => member.trim().length > 0 && member.length <= 256); +} + +export interface TraceContextOptions { + /** + * An inbound `traceparent` (typically from the incoming HTTP request) to + * continue. When absent a new root trace is started. + */ + traceparent?: string | null; + /** Optional inbound `tracestate` to forward. */ + tracestate?: string | null; + /** + * When true, a new root trace is started even if a `traceparent` is + * supplied. Use this to deliberately break the trace at the SDK boundary. + */ + startNewTrace?: boolean; + /** Force the sampled flag instead of honouring the inbound value. */ + sampled?: boolean; +} + +/** + * Resolve the trace context to attach to an outgoing request. + * + * When a valid inbound `traceparent` is present, a **new span id** is minted + * for this request while keeping the same trace id, which is what links the + * backend's work to the caller's trace. + */ +export function resolveTraceContext( + options: TraceContextOptions = {}, +): { traceparent: string; tracestate?: string } { + const inbound = options.startNewTrace + ? null + : parseTraceparent(options.traceparent); + + const traceId = inbound?.traceId ?? randomHex(16); + const spanId = randomHex(8); + const sampled = options.sampled ?? inbound?.sampled ?? true; + + const result: { traceparent: string; tracestate?: string } = { + traceparent: formatTraceparent({ traceId, spanId, sampled }), + }; + + // Only forward tracestate alongside a valid traceparent; the spec ties the + // two together and a stale tracestate with a new trace is meaningless. + if (inbound && isValidTracestate(options.tracestate)) { + result.tracestate = options.tracestate as string; + } + + return result; +} + +/** + * Apply trace headers to an outgoing request's header set. + * + * An existing `traceparent` is left untouched: a caller that already set the + * header (for example from a live OpenTelemetry context) knows better than we + * do. Otherwise a context is resolved, which continues the inbound trace when + * one was supplied and starts a fresh root trace when it was not. + */ +export function injectTraceHeaders( + headers: Headers, + options: TraceContextOptions = {}, +): Headers { + if (!headers.has("traceparent")) { + const context = resolveTraceContext(options); + headers.set("traceparent", context.traceparent); + if (context.tracestate) { + headers.set("tracestate", context.tracestate); + } + } + + // Never forward tracestate on its own: it is meaningless without the + // traceparent it belongs to, and the spec pairs the two. + if (isValidTracestate(options.tracestate) && !headers.has("tracestate")) { + headers.set("tracestate", options.tracestate as string); + } + + return headers; +} diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 16b2bf79..4787390b 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -1,3 +1,4 @@ +import type { TraceContextOptions } from "./tracecontext"; import type * as StellarSdk from "@stellar/stellar-sdk"; export interface BridgeWatchSdkConfig { @@ -8,8 +9,16 @@ export interface BridgeWatchSdkConfig { allowHttp?: boolean; defaultFee?: string; defaultTimeoutSeconds?: number; + /** + * W3C trace context to propagate on outgoing API requests. Supply the + * inbound `traceparent`/`tracestate` from the current HTTP request to + * correlate SDK calls with the caller's trace. + */ + tracing?: TraceContextOptions; } + + export interface InvokeContractParams { sourcePublicKey: string; method: string;