Skip to content
Draft
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
103 changes: 103 additions & 0 deletions packages/collector/test/integration/currencies/databases/pg/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ pool.query(createTableQuery, err => {
}
});

// Create a stored procedure for testing
const createProcedureQuery = `
CREATE OR REPLACE FUNCTION get_user_by_name(user_name VARCHAR)
RETURNS TABLE(id INT, name VARCHAR, email VARCHAR) AS $$
BEGIN
RETURN QUERY SELECT users.id, users.name, users.email FROM users WHERE users.name = user_name;
END;
$$ LANGUAGE plpgsql;
`;

pool.query(createProcedureQuery, err => {
if (err) {
log('Failed to create stored procedure', err);
}
});

if (process.env.WITH_STDOUT) {
app.use(morgan(`${logPrefix}:method :url :status`));
}
Expand Down Expand Up @@ -105,6 +121,93 @@ app.get('/parameterized-query', async (req, res) => {
res.json({});
});

app.get('/bind-variables-test', async (req, res) => {
// Test with string query and array parameters
await client.query('SELECT * FROM users WHERE name = testuser AND email = test@example.com');

// Test with config object containing values
await pool.query({
text: 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *',
values: ['bindtest', 'bindtest@example.com']
});

res.json({ success: true });
});

app.get('/stored-procedure-test', async (req, res) => {
// First insert a test user
await client.query('INSERT INTO users(name, email) VALUES($1, $2) ON CONFLICT DO NOTHING', [
'proceduretest',
'procedure@example.com'
]);

// Call stored procedure with bind variable
const result = await client.query('SELECT * FROM get_user_by_name($1)', ['proceduretest']);

res.json({ success: true, rows: result.rows });
});

app.get('/all-data-types-test', async (req, res) => {
// Test with various data types to demonstrate masking

// 1. String values
await client.query('SELECT $1::text as string_value', ['sensitive_password_123']);

// 2. Number values (integer and float)
await client.query('SELECT $1::integer as int_value, $2::numeric as float_value', [42, 3.14159]);

// 3. Boolean value
await client.query('SELECT $1::boolean as bool_value', [true]);

// 4. null and undefined (null in SQL)
await client.query('SELECT $1 as null_value', [null]);

// 5. Date object
await client.query('SELECT $1::timestamp as date_value', [new Date('2024-01-15T10:30:00Z')]);

// 6. JSON object
await client.query('SELECT $1::jsonb as json_value', [
JSON.stringify({ user: 'john', email: 'john@example.com', preferences: { theme: 'dark', notifications: true } })
]);

// 7. Array (as JSON string for PostgreSQL)
await client.query('SELECT $1::jsonb as array_value', [JSON.stringify([1, 2, 3, 4, 5])]);

// 8. Nested JSON with arrays
await client.query('SELECT $1::jsonb as nested_value', [
JSON.stringify({
users: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
],
metadata: { created: '2024-01-01', version: 1 }
})
]);

// 9. Buffer/Binary data (bytea in PostgreSQL)
const imageBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]); // JPEG header
await client.query('SELECT $1::bytea as binary_value', [imageBuffer]);

// 10. Large buffer (simulating file upload)
const largeBuffer = Buffer.alloc(1024); // 1KB buffer
await client.query('SELECT $1::bytea as large_binary', [largeBuffer]);

// 11. Mixed types in single query
await client.query('SELECT $1::text, $2::integer, $3::boolean, $4::jsonb, $5::bytea', [
'user@example.com',
12345,
false,
JSON.stringify({ key: 'value' }),
Buffer.from('secret')
]);

res.json({
success: true,
message: 'All data types tested',
note: 'Check spans to see masked bind variables'
});
});

app.get('/pool-string-insert', (req, res) => {
const insert = 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *';
const values = ['beaker', 'beaker@muppets.com'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,217 @@ module.exports = function (name, version, isLatest) {
)
));

it('must collect bind variables from parameterized queries', () =>
controls
.sendRequest({
method: 'GET',
path: '/bind-variables-test'
})
.then(() =>
retry(() =>
agentControls.getSpans().then(spans => {
verifyHttpEntry(spans, '/bind-variables-test');

// Verify first query with string and array parameters
let selectQuery = getSpansByName(spans, 'postgres');

console.log('SPAN SELECT QUERY: ', selectQuery[0].data);

selectQuery = selectQuery.find(
span => span.data.pg.stmt === 'SELECT * FROM users WHERE name = $1 AND email = $2'
);
expect(selectQuery).to.exist;
expect(selectQuery.data.pg.params).to.exist;
expect(selectQuery.data.pg.params).to.be.an('array');
expect(selectQuery.data.pg.params).to.have.lengthOf(2);
// Verify values are masked (first 2 and last 2 chars visible, exact length preserved)
// 'testuser' (8 chars) -> 'te****er'
expect(selectQuery.data.pg.params[0]).to.equal('te****er');
expect(selectQuery.data.pg.params[0]).to.have.lengthOf(8);
// 'test@example.com' (16 chars) -> 'te************om'
expect(selectQuery.data.pg.params[1]).to.equal('te************om');
expect(selectQuery.data.pg.params[1]).to.have.lengthOf(16);

// Verify second query with config object containing values
const insertQuery = getSpansByName(spans, 'postgres').find(
span => span.data.pg.stmt === 'INSERT INTO users(name, email) VALUES($1, $2) RETURNING *'
);
expect(insertQuery).to.exist;
expect(insertQuery.data.pg.params).to.exist;
expect(insertQuery.data.pg.params).to.be.an('array');
expect(insertQuery.data.pg.params).to.have.lengthOf(2);
// Verify values are masked with exact length preserved
// 'bindtest' (8 chars) -> 'bi****st'
expect(insertQuery.data.pg.params[0]).to.equal('bi****st');
expect(insertQuery.data.pg.params[0]).to.have.lengthOf(8);
// 'bindtest@example.com' (20 chars) -> 'bi****************om'
expect(insertQuery.data.pg.params[1]).to.equal('bi****************om');
expect(insertQuery.data.pg.params[1]).to.have.lengthOf(20);
})
)
));

it('must collect bind variables when calling stored procedures', () =>
controls
.sendRequest({
method: 'GET',
path: '/stored-procedure-test'
})
.then(() =>
retry(() =>
agentControls.getSpans().then(spans => {
verifyHttpEntry(spans, '/stored-procedure-test');

// Verify INSERT query with bind variables
const insertQuery = getSpansByName(spans, 'postgres').find(
span => span.data.pg.stmt && span.data.pg.stmt.includes('INSERT INTO users(name, email) VALUES($1, $2)')
);
expect(insertQuery).to.exist;
expect(insertQuery.data.pg.params).to.exist;
expect(insertQuery.data.pg.params).to.be.an('array');
expect(insertQuery.data.pg.params).to.have.lengthOf(2);
// Verify values are masked with exact length preserved
// 'proceduretest' (13 chars) -> 'pr*********st'
expect(insertQuery.data.pg.params[0]).to.equal('pr*********st');
expect(insertQuery.data.pg.params[0]).to.have.lengthOf(13);
// 'procedure@example.com' (21 chars) -> 'pr*****************om'
expect(insertQuery.data.pg.params[1]).to.equal('pr*****************om');
expect(insertQuery.data.pg.params[1]).to.have.lengthOf(21);

// Verify stored procedure call with bind variable
const procedureCall = getSpansByName(spans, 'postgres').find(
span => span.data.pg.stmt === 'SELECT * FROM get_user_by_name($1)'
);
expect(procedureCall).to.exist;
expect(procedureCall.data.pg.params).to.exist;
expect(procedureCall.data.pg.params).to.be.an('array');
expect(procedureCall.data.pg.params).to.have.lengthOf(1);
// Verify value is masked with exact length preserved
// 'proceduretest' (13 chars) -> 'pr*********st'
expect(procedureCall.data.pg.params[0]).to.equal('pr*********st');
expect(procedureCall.data.pg.params[0]).to.have.lengthOf(13);
})
)
));

it('must collect and mask all data types correctly', () =>
controls
.sendRequest({
method: 'GET',
path: '/all-data-types-test'
})
.then(() =>
retry(() =>
agentControls.getSpans().then(spans => {
verifyHttpEntry(spans, '/all-data-types-test');
const pgSpans = getSpansByName(spans, 'postgres');

// 1. String value test
const stringQuery = pgSpans.find(span => span.data.pg.stmt.includes('string_value'));
expect(stringQuery).to.exist;
expect(stringQuery.data.pg.params).to.exist;
// 'sensitive_password_123' (23 chars) -> 'se*******************23'
expect(stringQuery.data.pg.params[0]).to.equal('se******************23');
expect(stringQuery.data.pg.params[0]).to.have.lengthOf(22);

// 2. Number values test
const numberQuery = pgSpans.find(span => span.data.pg.stmt.includes('int_value'));
expect(numberQuery).to.exist;
expect(numberQuery.data.pg.params).to.have.lengthOf(2);
// 42 -> '**'
expect(numberQuery.data.pg.params[0]).to.equal('**');
// 3.14159 -> '3.***59'
expect(numberQuery.data.pg.params[1]).to.equal('3.***59');

// 3. Boolean value test
const boolQuery = pgSpans.find(span => span.data.pg.stmt.includes('bool_value'));
expect(boolQuery).to.exist;
// true -> 't**e'
expect(boolQuery.data.pg.params[0]).to.equal('t**e');

// 4. Null value test
const nullQuery = pgSpans.find(span => span.data.pg.stmt.includes('null_value'));
expect(nullQuery).to.exist;
expect(nullQuery.data.pg.params[0]).to.equal('<null>');

// 5. Date value test
const dateQuery = pgSpans.find(span => span.data.pg.stmt.includes('date_value'));
expect(dateQuery).to.exist;
// Date ISO string is masked
expect(dateQuery.data.pg.params[0]).to.match(/^20\*+0Z$/);
expect(dateQuery.data.pg.params[0]).to.have.lengthOf(24);

// 6. JSON object test
const jsonQuery = pgSpans.find(span => span.data.pg.stmt.includes('json_value'));
expect(jsonQuery).to.exist;
// JSON is now masked with structure preserved
const parsedJson = JSON.parse(jsonQuery.data.pg.params[0]);
expect(parsedJson).to.have.property('u**r', 'j**n');
expect(parsedJson).to.have.property('em**l', 'jo**************om');
expect(parsedJson).to.have.property('pr********s');
expect(parsedJson['pr********s']).to.have.property('th**e', 'd**k');
expect(parsedJson['pr********s']).to.have.property('no*********ns', 't**e');

// 7. Array test
const arrayQuery = pgSpans.find(span => span.data.pg.stmt.includes('array_value'));
expect(arrayQuery).to.exist;
// Array is now masked with structure preserved
const parsedArray = JSON.parse(arrayQuery.data.pg.params[0]);
expect(parsedArray).to.be.an('array');
expect(parsedArray).to.have.lengthOf(5);
expect(parsedArray[0]).to.equal('1');
expect(parsedArray[1]).to.equal('2');
expect(parsedArray[2]).to.equal('3');
expect(parsedArray[3]).to.equal('4');
expect(parsedArray[4]).to.equal('5');

// 8. Nested JSON test
const nestedQuery = pgSpans.find(span => span.data.pg.stmt.includes('nested_value'));
expect(nestedQuery).to.exist;
// Complex nested JSON is now masked with structure preserved
const parsedNested = JSON.parse(nestedQuery.data.pg.params[0]);
expect(parsedNested).to.have.property('us**s');
expect(parsedNested['us**s']).to.be.an('array');
expect(parsedNested['us**s']).to.have.lengthOf(2);
expect(parsedNested['us**s'][0]).to.have.property('*d', '1');
expect(parsedNested['us**s'][0]).to.have.property('n**e', 'Al**e');
expect(parsedNested['us**s'][0]).to.have.property('em**l', 'al**************om');
expect(parsedNested).to.have.property('me*****a');
expect(parsedNested['me*****a']).to.have.property('cr****d', '20********01');
expect(parsedNested['me*****a']).to.have.property('ve****n', '1');

// 9. Buffer/Binary data test
const binaryQuery = pgSpans.find(span => span.data.pg.stmt.includes('binary_value'));
expect(binaryQuery).to.exist;
// Buffer shown as size
expect(binaryQuery.data.pg.params[0]).to.equal('<Buffer 8 bytes>');

// 10. Large buffer test
const largeBinaryQuery = pgSpans.find(span => span.data.pg.stmt.includes('large_binary'));
expect(largeBinaryQuery).to.exist;
expect(largeBinaryQuery.data.pg.params[0]).to.equal('<Buffer 1024 bytes>');

// 11. Mixed types in single query
const mixedQuery = pgSpans.find(span =>
span.data.pg.stmt.includes('SELECT $1::text, $2::integer, $3::boolean, $4::jsonb, $5::bytea')
);
expect(mixedQuery).to.exist;
expect(mixedQuery.data.pg.params).to.have.lengthOf(5);
// String: 'user@example.com' -> 'us************om'
expect(mixedQuery.data.pg.params[0]).to.equal('us************om');
// Number: 12345 -> '1***5'
expect(mixedQuery.data.pg.params[1]).to.equal('1***5');
// Boolean: false -> 'f***e'
expect(mixedQuery.data.pg.params[2]).to.equal('f***e');
// JSON: '{"key":"value"}' is now masked with structure preserved
const parsedMixed = JSON.parse(mixedQuery.data.pg.params[3]);
expect(parsedMixed).to.have.property('k*y', 'va**e');
// Buffer: '<Buffer 6 bytes>'
expect(mixedQuery.data.pg.params[4]).to.equal('<Buffer 6 bytes>');
})
)
));

it('must trace pooled select now', () =>
controls
.sendRequest({
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/tracing/instrumentation/databases/pg.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@ function instrumentedQuery(ctx, originalQuery, argsForOriginalQuery) {
kind: constants.EXIT
});
span.stack = tracingUtil.getStackTrace(instrumentedQuery);

// Extract bind variables/parameters
let params;
if (typeof config === 'string') {
// Query is a string, parameters might be in argsForOriginalQuery[1]
if (argsForOriginalQuery.length > 1 && Array.isArray(argsForOriginalQuery[1])) {
params = argsForOriginalQuery[1];
}
} else if (config && config.values) {
// Query config object with values property
params = config.values;
}

span.data.pg = {
stmt: tracingUtil.shortenDatabaseStatement(typeof config === 'string' ? config : config.text),
host,
Expand All @@ -66,6 +79,11 @@ function instrumentedQuery(ctx, originalQuery, argsForOriginalQuery) {
db
};

// Add masked bind parameters to span data if present
if (params && params.length > 0) {
span.data.pg.params = tracingUtil.maskBindVariables(params);
}

let originalCallback;
let callbackIndex = -1;
for (let i = 1; i < argsForOriginalQuery.length; i++) {
Expand Down
Loading