Skip to content

feat(NODE-7009): add client metadata on demand #4574

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 10, 2025
Merged
6 changes: 5 additions & 1 deletion src/cmap/connection_pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,13 +610,17 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
}

private createConnection(callback: Callback<Connection>) {
// Note that metadata and extendedMetadata may have changed on the client but have
// been frozen here, so we pull the extendedMetadata promise always from the client
// no mattter what options were set at the construction of the pool.
const connectOptions: ConnectionOptions = {
...this.options,
id: this.connectionCounter.next().value,
generation: this.generation,
cancellationToken: this.cancellationToken,
mongoLogger: this.mongoLogger,
authProviders: this.server.topology.client.s.authProviders
authProviders: this.server.topology.client.s.authProviders,
extendedMetadata: this.server.topology.client.options.extendedMetadata
};

this.pending++;
Expand Down
29 changes: 27 additions & 2 deletions src/cmap/handshake/client_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as os from 'os';
import * as process from 'process';

import { BSON, type Document, Int32 } from '../../bson';
import { MongoInvalidArgumentError } from '../../error';
import { MongoInvalidArgumentError, MongoRuntimeError } from '../../error';
import type { MongoOptions } from '../../mongo_client';
import { fileIsAccessible } from '../../utils';

Expand Down Expand Up @@ -90,7 +90,10 @@ export class LimitedSizeDocument {
}
}

type MakeClientMetadataOptions = Pick<MongoOptions, 'appName' | 'driverInfo'>;
type MakeClientMetadataOptions = Pick<
MongoOptions,
'appName' | 'driverInfo' | 'additionalDriverInfo'
>;
/**
* From the specs:
* Implementors SHOULD cumulatively update fields in the following order until the document is under the size limit:
Expand Down Expand Up @@ -119,6 +122,22 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe
version: version.length > 0 ? `${NODE_DRIVER_VERSION}|${version}` : NODE_DRIVER_VERSION
};

if (options.additionalDriverInfo == null) {
throw new MongoRuntimeError(
'Client options `additionalDriverInfo` must always default to an empty array'
);
}

// This is where we handle additional driver info added after client construction.
for (const { name: n = '', version: v = '' } of options.additionalDriverInfo) {
if (n.length > 0) {
driverInfo.name = `${driverInfo.name}|${n}`;
}
if (v.length > 0) {
driverInfo.version = `${driverInfo.version}|${v}`;
}
}

if (!metadataDocument.ifItFitsItSits('driver', driverInfo)) {
throw new MongoInvalidArgumentError(
'Unable to include driverInfo name and version, metadata cannot exceed 512 bytes'
Expand All @@ -130,6 +149,12 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe
runtimeInfo = `${runtimeInfo}|${platform}`;
}

for (const { platform: p = '' } of options.additionalDriverInfo) {
if (p.length > 0) {
runtimeInfo = `${runtimeInfo}|${p}`;
}
}

if (!metadataDocument.ifItFitsItSits('platform', runtimeInfo)) {
throw new MongoInvalidArgumentError(
'Unable to include driverInfo platform, metadata cannot exceed 512 bytes'
Expand Down
3 changes: 3 additions & 0 deletions src/connection_string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,9 @@ export function parseOptions(
}
);

// Set the default for the additional driver info.
mongoOptions.additionalDriverInfo = [];

mongoOptions.metadata = makeClientMetadata(mongoOptions);

mongoOptions.extendedMetadata = addContainerMetadata(mongoOptions.metadata).then(
Expand Down
46 changes: 42 additions & 4 deletions src/mongo_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { type TokenCache } from './cmap/auth/mongodb_oidc/token_cache';
import { AuthMechanism } from './cmap/auth/providers';
import type { LEGAL_TCP_SOCKET_OPTIONS, LEGAL_TLS_SOCKET_OPTIONS } from './cmap/connect';
import type { Connection } from './cmap/connection';
import type { ClientMetadata } from './cmap/handshake/client_metadata';
import {
addContainerMetadata,
type ClientMetadata,
makeClientMetadata
} from './cmap/handshake/client_metadata';
import type { CompressorName } from './cmap/wire_protocol/compression';
import { parseOptions, resolveSRVRecord } from './connection_string';
import { MONGO_CLIENT_EVENTS } from './constants';
Expand Down Expand Up @@ -398,9 +402,31 @@ export class MongoClient extends TypedEventEmitter<MongoClientEvents> implements
* The consolidate, parsed, transformed and merged options.
*/
public readonly options: Readonly<
Omit<MongoOptions, 'monitorCommands' | 'ca' | 'crl' | 'key' | 'cert'>
Omit<
MongoOptions,
| 'monitorCommands'
| 'ca'
| 'crl'
| 'key'
| 'cert'
| 'driverInfo'
| 'additionalDriverInfo'
| 'metadata'
| 'extendedMetadata'
>
> &
Pick<MongoOptions, 'monitorCommands' | 'ca' | 'crl' | 'key' | 'cert'>;
Pick<
MongoOptions,
| 'monitorCommands'
| 'ca'
| 'crl'
| 'key'
| 'cert'
| 'driverInfo'
| 'additionalDriverInfo'
| 'metadata'
| 'extendedMetadata'
>;

constructor(url: string, options?: MongoClientOptions) {
super();
Expand Down Expand Up @@ -459,6 +485,18 @@ export class MongoClient extends TypedEventEmitter<MongoClientEvents> implements
await this.close();
}

/**
* Append metadata to the client metadata after instantiation.
* @param driverInfo - Information about the application or library.
*/
appendMetadata(driverInfo: DriverInfo) {
this.options.additionalDriverInfo.push(driverInfo);
this.options.metadata = makeClientMetadata(this.options);
this.options.extendedMetadata = addContainerMetadata(this.options.metadata)
.then(undefined, squashError)
.then(result => result ?? {}); // ensure Promise<Document>
}

/** @internal */
private checkForNonGenuineHosts() {
const documentDBHostnames = this.options.hosts.filter((hostAddress: HostAddress) =>
Expand Down Expand Up @@ -1041,8 +1079,8 @@ export interface MongoOptions
dbName: string;
/** @deprecated - Will be made internal in a future major release. */
metadata: ClientMetadata;
/** @internal */
extendedMetadata: Promise<Document>;
additionalDriverInfo: DriverInfo[];
/** @internal */
autoEncrypter?: AutoEncrypter;
/** @internal */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ describe('Connection', function () {
...commonConnectOptions,
connectionType: Connection,
...this.configuration.options,
metadata: makeClientMetadata({ driverInfo: {} }),
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }),
extendedMetadata: addContainerMetadata(
makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] })
)
};

let conn;
Expand All @@ -72,8 +74,10 @@ describe('Connection', function () {
connectionType: Connection,
...this.configuration.options,
monitorCommands: true,
metadata: makeClientMetadata({ driverInfo: {} }),
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }),
extendedMetadata: addContainerMetadata(
makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] })
)
};

let conn;
Expand Down Expand Up @@ -104,8 +108,10 @@ describe('Connection', function () {
connectionType: Connection,
...this.configuration.options,
monitorCommands: true,
metadata: makeClientMetadata({ driverInfo: {} }),
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }),
extendedMetadata: addContainerMetadata(
makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] })
)
};

let conn;
Expand Down
159 changes: 159 additions & 0 deletions test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
LEGACY_HELLO_COMMAND,
type MongoClient
} from '../../mongodb';
import { sleep } from '../../tools/utils';

type EnvironmentVariables = Array<[string, string]>;

Expand Down Expand Up @@ -194,3 +195,161 @@ describe('Handshake Prose Tests', function () {
});
});
});

describe('Client Metadata Update Prose Tests', function () {
let client: MongoClient;

afterEach(async function () {
await client?.close();
sinon.restore();
});

describe('Test 1: Test that the driver updates metadata', function () {
let initialClientMetadata;
let updatedClientMetadata;

const tests = [
{ testCase: 1, name: 'framework', version: '2.0', platform: 'Framework Platform' },
{ testCase: 2, name: 'framework', version: '2.0' },
{ testCase: 3, name: 'framework', platform: 'Framework Platform' },
{ testCase: 4, name: 'framework' }
];

for (const { testCase, name, version, platform } of tests) {
context(`Case: ${testCase}`, function () {
// 1. Create a `MongoClient` instance with the following:
// - `maxIdleTimeMS` set to `1ms`
// - Wrapping library metadata:
// | Field | Value |
// | -------- | ---------------- |
// | name | library |
// | version | 1.2 |
// | platform | Library Platform |
// 2. Send a `ping` command to the server and verify that the command succeeds.
// 3. Save intercepted `client` document as `initialClientMetadata`.
// 4. Wait 5ms for the connection to become idle.
beforeEach(async function () {
client = this.configuration.newClient(
{},
{
maxIdleTimeMS: 1,
driverInfo: { name: 'library', version: '1.2', platform: 'Library Platform' }
}
);

sinon.stub(Connection.prototype, 'command').callsFake(async function (ns, cmd, options) {
// @ts-expect-error: sinon will place wrappedMethod on the command method.
const command = Connection.prototype.command.wrappedMethod.bind(this);

if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) {
if (!initialClientMetadata) {
initialClientMetadata = cmd.client;
} else {
updatedClientMetadata = cmd.client;
}
}
return command(ns, cmd, options);
});

await client.db('test').command({ ping: 1 });
await sleep(5);
});

it('appends the metadata', async function () {
// 1. Append the `DriverInfoOptions` from the selected test case to the `MongoClient` metadata.
// 2. Send a `ping` command to the server and verify:
// - The command succeeds.
// - The framework metadata is appended to the existing `DriverInfoOptions` in the `client.driver` fields of the `hello`
// command, with values separated by a pipe `|`.
client.appendMetadata({ name, version, platform });
await client.db('test').command({ ping: 1 });

// Since we have our own driver metadata getting added, we really want to just
// assert that the last driver info values are appended at the end.
expect(updatedClientMetadata.driver.name).to.match(/^.*\|framework$/);
expect(updatedClientMetadata.driver.version).to.match(
new RegExp(`^.*\\|${version ? version : '1.2'}$`)
);
expect(updatedClientMetadata.platform).to.match(
new RegExp(`^.*\\|${platform ? platform : 'Library Platform'}$`)
);
// - All other subfields in the client document remain unchanged from initialClientMetadata.
// (Note os is the only one getting set in these tests)
expect(updatedClientMetadata.os).to.deep.equal(initialClientMetadata.os);
});
});
}
});

describe('Test 2: Multiple Successive Metadata Updates', function () {
let initialClientMetadata;
let updatedClientMetadata;

const tests = [
{ testCase: 1, name: 'framework', version: '2.0', platform: 'Framework Platform' },
{ testCase: 2, name: 'framework', version: '2.0' },
{ testCase: 3, name: 'framework', platform: 'Framework Platform' },
{ testCase: 4, name: 'framework' }
];

for (const { testCase, name, version, platform } of tests) {
context(`Case: ${testCase}`, function () {
// 1. Create a `MongoClient` instance with the following:
// - `maxIdleTimeMS` set to `1ms`
// 2. Append the following `DriverInfoOptions` to the `MongoClient` metadata:
// | Field | Value |
// | -------- | ---------------- |
// | name | library |
// | version | 1.2 |
// | platform | Library Platform |
// 3. Send a `ping` command to the server and verify that the command succeeds.
// 4. Save intercepted `client` document as `updatedClientMetadata`.
// 5. Wait 5ms for the connection to become idle.
beforeEach(async function () {
client = this.configuration.newClient({}, { maxIdleTimeMS: 1 });
client.appendMetadata({ name: 'library', version: '1.2', platform: 'Library Platform' });

sinon.stub(Connection.prototype, 'command').callsFake(async function (ns, cmd, options) {
// @ts-expect-error: sinon will place wrappedMethod on the command method.
const command = Connection.prototype.command.wrappedMethod.bind(this);

if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) {
if (!initialClientMetadata) {
initialClientMetadata = cmd.client;
} else {
updatedClientMetadata = cmd.client;
}
}
return command(ns, cmd, options);
});

await client.db('test').command({ ping: 1 });
await sleep(5);
});

it('appends the metadata', async function () {
// 1. Append the `DriverInfoOptions` from the selected test case to the `MongoClient` metadata.
// 2. Send a `ping` command to the server and verify:
// - The command succeeds.
// - The framework metadata is appended to the existing `DriverInfoOptions` in the `client.driver` fields of the `hello`
// command, with values separated by a pipe `|`.
client.appendMetadata({ name, version, platform });
await client.db('test').command({ ping: 1 });

// Since we have our own driver metadata getting added, we really want to just
// assert that the last driver info values are appended at the end.
expect(updatedClientMetadata.driver.name).to.match(/^.*\|framework$/);
expect(updatedClientMetadata.driver.version).to.match(
new RegExp(`^.*\\|${version ? version : '1.2'}$`)
);
expect(updatedClientMetadata.platform).to.match(
new RegExp(`^.*\\|${platform ? platform : 'Library Platform'}$`)
);
// - All other subfields in the client document remain unchanged from initialClientMetadata.
// (Note os is the only one getting set in these tests)
expect(updatedClientMetadata.os).to.deep.equal(initialClientMetadata.os);
});
});
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { join } from 'path';

import { loadSpecTests } from '../../spec';
import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner';

describe('MongoDB Handshake Tests (Unified)', function () {
runUnifiedSuite(loadSpecTests(join('mongodb-handshake')));
});
Loading