Skip to content

Commit fdfce7d

Browse files
committed
feat(core): report the client fingerprint on every request
The client sent no User-Agent at all, so the service could not tell which SDK, version or platform a request came from and had to learn about breaking changes from customer reports. Send the same identity headers the Go and Python SDKs send, using their spellings so the three languages aggregate into the same buckets, and keep the signed-URL download leg free of them.
1 parent bf513f8 commit fdfce7d

7 files changed

Lines changed: 126 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,25 @@ Pass `signal` to cancel a request yourself. The deadline stays armed while the r
232232
await client.sessions.list({}, { signal: AbortSignal.timeout(5_000) });
233233
```
234234

235+
## Client fingerprint
236+
237+
Every API request identifies the client so the service can tell which SDK versions are in use before changing a response shape:
238+
239+
| Header | Value |
240+
| --- | --- |
241+
| `User-Agent` | `qca-js/<version>` |
242+
| `X-Qoder-Lang` | `js` |
243+
| `X-Qoder-Package-Version` | the published package version, also exported as `VERSION` |
244+
| `X-Qoder-OS` / `X-Qoder-Arch` | normalized platform, e.g. `MacOS` / `arm64` |
245+
| `X-Qoder-Runtime` / `X-Qoder-Runtime-Version` | `node` and its version |
246+
| `X-Qoder-Retry-Count` / `X-Qoder-Timeout` | attempt number, and the request deadline in seconds |
247+
248+
The signed-URL leg of a file download carries none of these, so nothing is disclosed to object storage. Any of them can be replaced:
249+
250+
```ts
251+
const client = new ForwardClient({ defaultHeaders: { 'User-Agent': 'my-app/2.1' } });
252+
```
253+
235254
## Auto-pagination
236255

237256
List methods return an async-iterable page. Iterating the result fetches subsequent pages as needed, carrying your filter parameters forward.

‎scripts/build.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
import { rm, mkdir, writeFile } from 'node:fs/promises';
1+
import { rm, mkdir, writeFile, readFile } from 'node:fs/promises';
22
import { spawnSync } from 'node:child_process';
33

4+
// The reported version is a hand-written constant, so a release that forgets to
5+
// bump it would make the SDK report a version that was never published.
6+
const declared = (await readFile(new URL('../src/version.ts', import.meta.url), 'utf8')).match(/VERSION = '(.*)'/)?.[1];
7+
const published = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
8+
if (declared !== published) {
9+
console.error(`src/version.ts declares ${declared} but package.json is ${published}`);
10+
process.exit(1);
11+
}
12+
413
await rm(new URL('../dist/', import.meta.url), { recursive: true, force: true });
514
for (const config of ['tsconfig.json', 'tsconfig.esm.json']) {
615
const result = spawnSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', config], { stdio: 'inherit' });

‎src/core/client.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { type Credential, readEnv } from './credentials.js';
33
import { APIConnectionError, APIConnectionTimeoutError, APIError, APIUserAbortError, QoderError } from './error.js';
44
import { Page, PagePromise, type PageResponse, type PaginationMode } from './pagination.js';
55
import { Stream } from './streaming.js';
6+
import { VERSION } from '../version.js';
7+
import { platformHeaders } from './detect-platform.js';
68

79
export type HeadersLike = HeadersInit | Record<string, string | null | undefined>;
810
export type Middleware = (request: Request, next: (request: Request) => Promise<Response>) => Promise<Response>;
@@ -177,7 +179,12 @@ export class APIClient {
177179
}
178180
const method = options.method.toUpperCase();
179181
const value = await withSignal(Promise.resolve(options.body), callerSignal, () => false);
180-
const headers = storage ? new Headers() : mergeHeaders({ Accept: options.responseType === 'stream' ? 'text/event-stream' : 'application/json' }, this.options.defaultHeaders, options.headers);
182+
const headers = storage ? new Headers() : mergeHeaders({
183+
Accept: options.responseType === 'stream' ? 'text/event-stream' : 'application/json',
184+
'User-Agent': `qca-js/${VERSION}`,
185+
...platformHeaders(),
186+
...(timeout ? { 'X-Qoder-Timeout': String(Math.trunc(timeout / 1000)) } : {}),
187+
}, this.options.defaultHeaders, options.headers);
181188
if (options.idempotencyKey !== undefined) headers.set('Idempotency-Key', options.idempotencyKey);
182189
let body: BodyInit | undefined;
183190
if (value !== undefined) {

‎src/core/detect-platform.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { VERSION } from '../version.js';
2+
3+
// The spellings match the Go SDK's convention/requestconfig.go on purpose: the
4+
// server aggregates SDK usage across languages, so the same machine has to land in
5+
// the same bucket no matter which SDK called. process.arch and Go's runtime.GOARCH
6+
// name the same architecture differently, hence the aliases.
7+
const OS_NAMES: Record<string, string> = {
8+
darwin: 'MacOS',
9+
win32: 'Windows',
10+
windows: 'Windows',
11+
linux: 'Linux',
12+
ios: 'iOS',
13+
android: 'Android',
14+
freebsd: 'FreeBSD',
15+
openbsd: 'OpenBSD',
16+
};
17+
18+
const ARCH_NAMES: Record<string, string> = {
19+
ia32: 'x32',
20+
x32: 'x32',
21+
x64: 'x64',
22+
x86_64: 'x64',
23+
arm: 'arm',
24+
arm64: 'arm64',
25+
aarch64: 'arm64',
26+
};
27+
28+
// The package targets Node (see engines), and has no @types/node, so reach for
29+
// process the same way credentials.ts does.
30+
function nodeProcess(): { platform?: string; arch?: string; version?: string } {
31+
return (globalThis as unknown as { process?: { platform?: string; arch?: string; version?: string } }).process ?? {};
32+
}
33+
34+
export function platformHeaders(): Record<string, string> {
35+
const { platform, arch, version } = nodeProcess();
36+
return {
37+
'X-Qoder-Lang': 'js',
38+
'X-Qoder-Package-Version': VERSION,
39+
'X-Qoder-OS': platform ? OS_NAMES[platform] ?? `Other:${platform}` : 'Unknown',
40+
'X-Qoder-Arch': arch ? ARCH_NAMES[arch] ?? `other:${arch}` : 'unknown',
41+
// Only Node is supported today; report the runtime we actually detect rather
42+
// than claiming node when process is missing.
43+
'X-Qoder-Runtime': version ? 'node' : 'unknown',
44+
'X-Qoder-Runtime-Version': version ? version.replace(/^v/, '') : 'unknown',
45+
};
46+
}

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { ForwardClient } from './forward/forwardClient.js';
22
export { ManagedClient } from './managed/managedClient.js';
3+
export { VERSION } from './version.js';
34
export * from './core/client.js';
45
export * from './core/error.js';
56
export * from './core/credentials.js';

‎src/version.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Reported to the server in User-Agent and X-Qoder-Package-Version. It must match
2+
// the version in package.json, otherwise the server-side SDK version statistics
3+
// describe a version that was never published; `npm run build` compares the two.
4+
// Declared here rather than imported from package.json so bundlers do not have to
5+
// resolve JSON from outside src/.
6+
export const VERSION = '0.0.1-dev.1';

‎tests/protocol.test.mjs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,42 @@ for (const mode of ['forward', 'managed']) {
9595
const res = mode === 'forward' ? await c.files.download('file') : await c.files.download('file', {});
9696
assert.equal(await res.text(), 'actual file'); assert.equal(calls, 2);
9797
});
98+
test(`${mode}: client fingerprint identifies the SDK but never reaches storage`, async () => {
99+
const seen = [];
100+
const c = testClient(mode, req => {
101+
seen.push(req.headers);
102+
if (new URL(req.url).host === 'qoder.test') return response({ url: 'https://storage.test/object?signature=signed' });
103+
return response('actual file');
104+
}, { timeout: 30_000 });
105+
const res = mode === 'forward' ? await c.files.download('file') : await c.files.download('file', {});
106+
assert.equal(await res.text(), 'actual file');
107+
const [api, storage] = seen;
108+
assert.equal(api.get('user-agent'), `qca-js/${sdk.VERSION}`);
109+
assert.equal(api.get('x-qoder-lang'), 'js');
110+
assert.equal(api.get('x-qoder-package-version'), sdk.VERSION);
111+
assert.equal(api.get('x-qoder-runtime'), 'node');
112+
assert.equal(api.get('x-qoder-runtime-version'), process.version.replace(/^v/, ''));
113+
// Normalized rather than raw process.platform/arch, so the same machine lands
114+
// in the same server-side bucket as the Go and Python SDKs.
115+
assert.ok(['MacOS', 'Windows', 'Linux', 'iOS', 'Android', 'FreeBSD', 'OpenBSD'].includes(api.get('x-qoder-os')));
116+
assert.ok(['x32', 'x64', 'arm', 'arm64'].includes(api.get('x-qoder-arch')));
117+
assert.equal(api.get('x-qoder-timeout'), '30');
118+
for (const name of ['user-agent', 'x-qoder-lang', 'x-qoder-package-version', 'x-qoder-os', 'x-qoder-arch', 'x-qoder-runtime', 'x-qoder-timeout']) {
119+
assert.equal(storage.get(name), null, name);
120+
}
121+
});
122+
test(`${mode}: client fingerprint yields to caller headers and omits an absent deadline`, async () => {
123+
const seen = [];
124+
const c = testClient(mode, req => { seen.push(req.headers); return response({ data: [] }); }, {
125+
timeout: 0,
126+
defaultHeaders: { 'User-Agent': 'caller/1.0', 'X-Qoder-Lang': 'cli' },
127+
});
128+
const r = mode === 'forward' ? c.templates : c.agents;
129+
await r.list({});
130+
assert.equal(seen[0].get('user-agent'), 'caller/1.0');
131+
assert.equal(seen[0].get('x-qoder-lang'), 'cli');
132+
assert.equal(seen[0].get('x-qoder-timeout'), null);
133+
});
98134
test(`${mode}: multipart files retain relative names and metadata`, async () => {
99135
let calls = 0;
100136
const c = testClient(mode, async req => {

0 commit comments

Comments
 (0)