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
18 changes: 16 additions & 2 deletions sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BridgeWatchSdkConfig>;
/**
* `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<Omit<BridgeWatchSdkConfig, "tracing">> &
Pick<BridgeWatchSdkConfig, "tracing">;
private readonly server: StellarSdk.rpc.Server;
private connected = false;

Expand Down Expand Up @@ -49,8 +56,15 @@ export class BridgeWatchContractSdk {
}

private async fetchCompatibility<T>(path: string, version?: ApiVersion): Promise<T> {
// 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<T>;
Expand Down
1 change: 1 addition & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export * from "./client";
export * from "./contract";
export * from "./testing";
export * from "./compatibility";
export * from "./tracecontext";
export * from "./pagination";
137 changes: 137 additions & 0 deletions sdk/src/tracePropagation.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof stubFetch>): 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)$/
);
});
});
Loading
Loading