Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
239 changes: 239 additions & 0 deletions spec/MongoStorageAdapter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -824,4 +824,243 @@ describe_only_db('mongo')('MongoStorageAdapter', () => {
expect(roleIndexes.find(idx => idx.name === 'name_1')).toBeDefined();
});
});

describe('clientLogEvents', () => {
it('should log MongoDB client events when configured', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn');

const clientLogEvents = [
{
name: 'serverDescriptionChanged',
keys: ['address'],
logLevel: 'warn',
},
];

const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { clientLogEvents },
});

// Connect to trigger event listeners setup
await adapter.connect();

// Manually trigger the event to test the listener
const mockEvent = {
address: 'localhost:27017',
previousDescription: { type: 'Unknown' },
newDescription: { type: 'Standalone' },
};

adapter.client.emit('serverDescriptionChanged', mockEvent);

// Verify the log was called with the correct message
expect(logSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event serverDescriptionChanged:.*"address":"localhost:27017"/)
);

await adapter.handleShutdown();
});

it('should log entire event when keys are not specified', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'info');

const clientLogEvents = [
{
name: 'connectionPoolReady',
logLevel: 'info',
},
];

const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { clientLogEvents },
});

await adapter.connect();

const mockEvent = {
address: 'localhost:27017',
options: { maxPoolSize: 100 },
};

adapter.client.emit('connectionPoolReady', mockEvent);

expect(logSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event connectionPoolReady:.*"address":"localhost:27017".*"options"/)
);

await adapter.handleShutdown();
});

it('should extract nested keys using dot notation', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn');

const clientLogEvents = [
{
name: 'topologyDescriptionChanged',
keys: ['previousDescription.type', 'newDescription.type', 'newDescription.servers.size'],
logLevel: 'warn',
},
];

const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { clientLogEvents },
});

await adapter.connect();

const mockEvent = {
topologyId: 1,
previousDescription: { type: 'Unknown' },
newDescription: {
type: 'ReplicaSetWithPrimary',
servers: { size: 3 },
},
};

adapter.client.emit('topologyDescriptionChanged', mockEvent);

expect(logSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event topologyDescriptionChanged:.*"previousDescription.type":"Unknown".*"newDescription.type":"ReplicaSetWithPrimary".*"newDescription.servers.size":3/)
);

await adapter.handleShutdown();
});

it('should handle invalid log level gracefully', async () => {
const logger = require('../lib/logger').logger;
const infoSpy = spyOn(logger, 'info');

const clientLogEvents = [
{
name: 'connectionPoolReady',
keys: ['address'],
logLevel: 'invalidLogLevel', // Invalid log level
},
];

const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { clientLogEvents },
});

await adapter.connect();

const mockEvent = {
address: 'localhost:27017',
};

adapter.client.emit('connectionPoolReady', mockEvent);

// Should fallback to 'info' level
expect(infoSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event connectionPoolReady:.*"address":"localhost:27017"/)
);

await adapter.handleShutdown();
});

it('should handle Map and Set instances in events', async () => {
const logger = require('../lib/logger').logger;
const warnSpy = spyOn(logger, 'warn');

const clientLogEvents = [
{
name: 'customEvent',
logLevel: 'warn',
},
];

const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { clientLogEvents },
});

await adapter.connect();

const mockEvent = {
mapData: new Map([['key1', 'value1'], ['key2', 'value2']]),
setData: new Set([1, 2, 3]),
};

adapter.client.emit('customEvent', mockEvent);

// Should serialize Map and Set properly
expect(warnSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event customEvent:.*"mapData":\{"key1":"value1","key2":"value2"\}.*"setData":\[1,2,3\]/)
);

await adapter.handleShutdown();
});

it('should handle missing keys in event object', async () => {
const logger = require('../lib/logger').logger;
const infoSpy = spyOn(logger, 'info');

const clientLogEvents = [
{
name: 'testEvent',
keys: ['nonexistent.nested.key', 'another.missing'],
logLevel: 'info',
},
];

const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { clientLogEvents },
});

await adapter.connect();

const mockEvent = {
actualField: 'value',
};

adapter.client.emit('testEvent', mockEvent);

// Should handle missing keys gracefully with undefined values
expect(infoSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event testEvent:/)
);

await adapter.handleShutdown();
});

it('should handle circular references with fallback warning', async () => {
const logger = require('../lib/logger').logger;
const warnSpy = spyOn(logger, 'warn');

const clientLogEvents = [
{
name: 'circularEvent',
logLevel: 'info',
},
];

const adapter = new MongoStorageAdapter({
uri: databaseURI,
mongoOptions: { clientLogEvents },
});

await adapter.connect();

// Create circular reference
const mockEvent = { name: 'test' };
mockEvent.self = mockEvent;

adapter.client.emit('circularEvent', mockEvent);

// Should fallback to warning when JSON.stringify fails
expect(warnSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/MongoDB client event circularEvent logged with error:/)
);

await adapter.handleShutdown();
});
});
});
41 changes: 41 additions & 0 deletions src/Adapters/Storage/Mongo/MongoStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export class MongoStorageAdapter implements StorageAdapter {
_mongoOptions: Object;
_onchange: any;
_stream: any;
_clientLogEvents: ?Array<any>;
// Public
connectionPromise: ?Promise<any>;
database: any;
Expand All @@ -154,6 +155,7 @@ export class MongoStorageAdapter implements StorageAdapter {
this.enableSchemaHooks = !!mongoOptions.enableSchemaHooks;
this.schemaCacheTtl = mongoOptions.schemaCacheTtl;
this.disableIndexFieldValidation = !!mongoOptions.disableIndexFieldValidation;
this._clientLogEvents = mongoOptions.clientLogEvents;
// Remove Parse Server-specific options that should not be passed to MongoDB client
// Note: We only delete from this._mongoOptions, not from the original mongoOptions object,
// because other components (like DatabaseController) need access to these options
Expand All @@ -162,6 +164,7 @@ export class MongoStorageAdapter implements StorageAdapter {
'schemaCacheTtl',
'maxTimeMS',
'disableIndexFieldValidation',
'clientLogEvents',
'createIndexUserUsername',
'createIndexUserUsernameCaseInsensitive',
'createIndexUserEmail',
Expand Down Expand Up @@ -203,6 +206,44 @@ export class MongoStorageAdapter implements StorageAdapter {
client.on('close', () => {
delete this.connectionPromise;
});

// Set up client event logging if configured
if (this._clientLogEvents && Array.isArray(this._clientLogEvents)) {
this._clientLogEvents.forEach(eventConfig => {
client.on(eventConfig.name, event => {
try {
let logData = {};
if (!eventConfig.keys || eventConfig.keys.length === 0) {
logData = event;
} else {
eventConfig.keys.forEach(keyPath => {
logData[keyPath] = _.get(event, keyPath);
});
}

// Validate log level exists, fallback to 'info'
const logLevel = typeof logger[eventConfig.logLevel] === 'function' ? eventConfig.logLevel : 'info';

// Safe JSON serialization with Map/Set support
const logMessage = `MongoDB client event ${eventConfig.name}: ${JSON.stringify(logData, (key, value) => {
if (value instanceof Map) {
return Object.fromEntries(value);
}
if (value instanceof Set) {
return Array.from(value);
}
return value;
})}`;

logger[logLevel](logMessage);
} catch (error) {
// Fallback if serialization completely fails
logger.warn(`MongoDB client event ${eventConfig.name} logged with error: ${error.message}`);
}
});
});
}

this.client = client;
this.database = database;
})
Expand Down
6 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,12 @@ module.exports.DatabaseOptions = {
'The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead.',
action: parsers.numberParser('autoSelectFamilyAttemptTimeout'),
},
clientLogEvents: {
env: 'PARSE_SERVER_DATABASE_CLIENT_LOG_EVENTS',
help:
"An array of MongoDB client event configurations to enable logging of specific events. Each configuration object should contain:<br><ul><li>`name` (the event name, e.g., 'topologyDescriptionChanged', 'serverDescriptionChanged', 'connectionPoolCleared', 'connectionPoolReady')</li><li>`keys` (optional array of dot-notation paths to extract specific data from the event object; if not provided or empty, the entire event object will be logged)</li><li>`logLevel` (the log level to use for this event: 'error', 'warn', 'info', 'debug', etc.).</li></ul>",
action: parsers.arrayParser,
},
compressors: {
env: 'PARSE_SERVER_DATABASE_COMPRESSORS',
help:
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,15 @@ export interface FileUploadOptions {
enableForPublic: ?boolean;
}

export type ClientLogEvent = {
/* The MongoDB driver event name to listen for. */
name: string,
/* Optional array of dot-notation paths to extract specific data from the event object. If not provided or empty, the entire event object will be logged. */
keys?: string[],
/* The log level to use for this event. */
logLevel: string,
};

export interface DatabaseOptions {
/* Enables database real-time hooks to update single schema cache. Set to `true` if using multiple Parse Servers instances connected to the same database. Failing to do so will cause a schema change to not propagate to all instances and re-syncing will only happen when the instances restart. To use this feature with MongoDB, a replica set cluster with [change stream](https://docs.mongodb.com/manual/changeStreams/#availability) support is required.
:DEFAULT: false */
Expand Down Expand Up @@ -725,6 +734,8 @@ export interface DatabaseOptions {
createIndexRoleName: ?boolean;
/* Set to `true` to disable validation of index fields. When disabled, indexes can be created even if the fields do not exist in the schema. This can be useful when creating indexes on fields that will be added later. */
disableIndexFieldValidation: ?boolean;
/* An array of MongoDB client event configurations to enable logging of specific events. Each configuration object should contain:<br><ul><li>`name` (the event name, e.g., 'topologyDescriptionChanged', 'serverDescriptionChanged', 'connectionPoolCleared', 'connectionPoolReady')</li><li>`keys` (optional array of dot-notation paths to extract specific data from the event object; if not provided or empty, the entire event object will be logged)</li><li>`logLevel` (the log level to use for this event: 'error', 'warn', 'info', 'debug', etc.).</li></ul> */
clientLogEvents: ?(ClientLogEvent[]);
}

export interface AuthAdapter {
Expand Down
Loading