From ec9e12012347c41fd324600db365fd812acd1c8f Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Sat, 22 Aug 2026 09:40:31 -0400 Subject: [PATCH] fix(streaming): identify the SDK on the WebSocket handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ActionCable upgrade request is an ordinary HTTP request, but the streaming client sent only `Authorization` on it. A streaming connection was therefore indistinguishable from a hand-rolled WebSocket client and dropped out of SDK attribution entirely, while the HTTP path has always been attributed correctly. The Go SDK already sets a User-Agent on its handshake (stream.go); this brings Node into line and adds the X-SDK-* pair the HTTP path sends. Also pins the server-side attribution contract from the SDK side. The server parses sdk_language/sdk_version out of the User-Agent with `/oilpriceapi-([a-z0-9-]+)\/v?([\d]+\.[\d]+\.?[\d]*)/i` (MinimalAnalyticsService#detect_sdk_info). A UA that stops matching it still returns 200 — the request succeeds and the SDK silently vanishes from adoption reporting. Nothing on either side guarded that shape, so the new tests assert the parsed language and version, not just a substring, across the HTTP, demo and WebSocket paths. Proven red-capable: rewriting buildUserAgent to emit `oilpriceapi-node-v1.2.6` fails 4 tests (2 pre-existing, 2 new); the new WebSocket test failed before the fix and passes after. Verified: npm run build clean; 508 tests pass, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JKAExynd9zoKwt6rYA66EA --- src/resources/streaming.ts | 9 +++++ tests/resources/streaming.test.ts | 15 +++++++ tests/user-agent.test.ts | 66 +++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/resources/streaming.ts b/src/resources/streaming.ts index 925f77c..644c658 100644 --- a/src/resources/streaming.ts +++ b/src/resources/streaming.ts @@ -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"; /** @@ -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; diff --git a/tests/resources/streaming.test.ts b/tests/resources/streaming.test.ts index 0b81b06..600ccbd 100644 --- a/tests/resources/streaming.test.ts +++ b/tests/resources/streaming.test.ts @@ -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 }).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", diff --git a/tests/user-agent.test.ts b/tests/user-agent.test.ts index 07249ae..0fb0200 100644 --- a/tests/user-agent.test.ts +++ b/tests/user-agent.test.ts @@ -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); + }); +});