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
9 changes: 9 additions & 0 deletions src/resources/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

import { EventEmitter } from "node:events";
import WebSocket from "ws";
import { buildUserAgent, SDK_NAME, SDK_VERSION } from "../version.js";
import type { OilPriceAPI } from "../client.js";

/**
Expand Down Expand Up @@ -261,9 +262,17 @@ export class PriceStreamSubscription extends EventEmitter {
connect(): void {
if (this.closed) return;

// Identify the SDK on the handshake exactly as the HTTP path does. The
// upgrade request is an ordinary HTTP request, so without these headers a
// streaming client is indistinguishable from a hand-rolled WebSocket and
// drops out of SDK attribution entirely. The Go SDK (stream.go) already
// sets the User-Agent here; this keeps the SDKs consistent.
const ws = new this.wsImpl(this.url, {
headers: {
Authorization: `Token ${this.apiKey}`,
"User-Agent": buildUserAgent(),
"X-SDK-Name": SDK_NAME,
"X-SDK-Version": SDK_VERSION,
},
});
this.ws = ws;
Expand Down
15 changes: 15 additions & 0 deletions tests/resources/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ describe("StreamingResource", () => {
});
});

it("sends the SDK User-Agent on the WebSocket handshake", async () => {
const { SDK_VERSION } = await import("../../src/index.js");
// Must match MinimalAnalyticsService#detect_sdk_info in oilpriceapi-api.
const SERVER_SDK_REGEX = /oilpriceapi-([a-z0-9-]+)\/v?([\d]+\.[\d]+\.?[\d]*)/i;

makeStream(client);
const ws = MockWebSocket.instances[0];
const headers = (ws.options as { headers?: Record<string, string> }).headers ?? {};

const match = SERVER_SDK_REGEX.exec(headers["User-Agent"] ?? "");
expect(match).not.toBeNull();
expect(match![1]).toBe("node");
expect(match![2]).toBe(SDK_VERSION);
});

it("derives a ws:// URL for an http baseUrl", () => {
const localClient = new OilPriceAPI({
apiKey: "k",
Expand Down
66 changes: 66 additions & 0 deletions tests/user-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,69 @@ describe("SDK Version", () => {
expect(SDK_VERSION).toBe(packageJson.default.version);
});
});

/**
* Server-side attribution conformance.
*
* `MinimalAnalyticsService#detect_sdk_info` in oilpriceapi-api parses
* sdk_language/sdk_version out of the User-Agent with exactly this regex.
* If a UA stops matching it, the request still succeeds but the SDK becomes
* invisible in adoption reporting — a silent failure that no HTTP-level test
* would catch. These tests pin the contract from the SDK side.
*
* Keep in step with app/services/minimal_analytics_service.rb (detect_sdk_info).
*/
const SERVER_SDK_REGEX = /oilpriceapi-([a-z0-9-]+)\/v?([\d]+\.[\d]+\.?[\d]*)/i;

describe("server attribution contract", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () =>
JSON.stringify({ status: "success", data: { price: 75.5, code: "WTI_USD" } }),
headers: new Map([["content-type", "application/json"]]),
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

it("HTTP User-Agent parses to language=node and the real SDK version", async () => {
const { SDK_VERSION } = await import("../src/index.js");
const client = new OilPriceAPI({ apiKey: "test_key" });

await client.getLatestPrices();

const [, options] = mockFetch.mock.calls[0];
const match = SERVER_SDK_REGEX.exec(options.headers["User-Agent"]);

expect(match).not.toBeNull();
expect(match![1]).toBe("node");
expect(match![2]).toBe(SDK_VERSION);
});

it("demo (unauthenticated) requests are attributed identically", async () => {
const { SDK_VERSION } = await import("../src/index.js");
const client = new OilPriceAPI({ apiKey: "test_key" });

mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({ status: "success", data: { commodities: [] } }),
headers: new Map([["content-type", "application/json"]]),
});

await client.getDemoCommodities();

const [, options] = mockFetch.mock.calls[0];
const match = SERVER_SDK_REGEX.exec(options.headers["User-Agent"]);

expect(match).not.toBeNull();
expect(match![1]).toBe("node");
expect(match![2]).toBe(SDK_VERSION);
});
});