diff --git a/Makefile b/Makefile index b00c620..5583726 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,16 @@ test-acceptance: @NODE_ENV=test ./node_modules/.bin/mocha \ -R $(REPORTER) -b test/acceptance/*.js +test-dynatrace: + @ENABLE_NODE_OPENTEL_TESTS=false NODE_ENV=test \ + ./node_modules/.bin/mocha -R $(REPORTER) -b \ + test/acceptance/db.ExtTrace.js + +test-opentelemetry: + @ENABLE_NODE_OPENTEL_TESTS=true NODE_ENV=test \ + ./node_modules/.bin/mocha -R $(REPORTER) -b \ + test/acceptance/db.ExtTrace.js + test-mock: @HDB_MOCK=1 $(MAKE) -s test diff --git a/extension/Dynatrace.js b/extension/Dynatrace.js new file mode 100644 index 0000000..0878395 --- /dev/null +++ b/extension/Dynatrace.js @@ -0,0 +1,186 @@ +// Copyright 2013 SAP AG. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http: //www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific +// language governing permissions and limitations under the License. +'use strict'; + +var ResultSet = require('../lib/protocol/ResultSet'); +const dynatrace = {}; +try { + // @dynatrace/oneagent-sdk must be installed by the application in order for + // the client to use it. + dynatrace.sdk = require('@dynatrace/oneagent-sdk'); + dynatrace.api = dynatrace.sdk.createInstance(); +} catch (err) { + // If module was not found, do not do anything +} + +function isDynatraceEnabled() { + if(dynatrace.api === undefined) { + return false; + } + const envVar = process.env.HDB_NODEJS_SKIP_DYNATRACE; + if(envVar && envVar != '0' && envVar.toLowerCase() != 'false') { + return false; + } + return true; +} + +function _dynatraceResultCallback(tracer, cb) { + return function (err, ...args) { + var results = args[0]; + + // With DB calls, the first argument can potentially be output parameters + // In that case, we consider the next parameter + if (typeof results === 'object' && results !== null && !Array.isArray(results)) { + results = args[1]; + } + + if (err) { + tracer.error(err); + } else if(results !== undefined) { + // In 0.19.12, results will typically be an array, but for non-batch insert / + // delete / update, results will be a number. This may be changed later to + // match hana-client which returns rows affected as a number even for batches. + tracer.setResultData({ + rowsReturned: (results && results.length) || results + }); + } + tracer.end(cb, err, ...args); + }; +} + +function _dynatraceResultSetCallback(tracer, cb) { + return function (err, ...args) { + var resultSet = args[0]; + + // With DB calls, the first argument can potentially be output parameters + // In that case, we consider the next parameter + if (typeof resultSet === 'object' && resultSet !== null && !(resultSet instanceof ResultSet) + && !Array.isArray(resultSet)) { + resultSet = args[1]; + } + + if (err) { + tracer.error(err); + } else if(resultSet instanceof ResultSet) { + const rowCount = resultSet.getRowCount(); + // A negative rowCount means the number of rows is unknown. + // This happens if the client hasn't received the last fetch chunk yet (with default server configuration, + // this happens if the result set is larger than 32 rows) + if(rowCount >= 0) { + tracer.setResultData({rowsReturned: rowCount}); + } + } else if (resultSet !== undefined) { + // Same as above, sometimes resultSet can be a number for non-batch insert / delete / update + tracer.setResultData({ + rowsReturned: (resultSet && resultSet.length) || resultSet + }); + } + tracer.end(cb, err, ...args); + }; +} + +function _ExecuteWrapperFn(stmtOrConn, conn, execFn, resultCB, sql) { + // connection exec args = [sql, options, callback] --> options is optional + // stmt exec args = [values, options, callback] --> options is optional + return function (...args) { + if(stmtOrConn === conn && args.length > 0) { + sql = args[0]; + } + if(typeof(sql) !== 'string') { + sql = ''; // execute will fail, but need sql for when the error is traced + } + // get dbInfo from the conn in case it changes since the first time dynatraceConnection was called + const tracer = dynatrace.api.traceSQLDatabaseRequest(conn._dbInfo, {statement: sql}); + var cb; + if (args.length > 0 && typeof args[args.length - 1] === 'function') { + cb = args[args.length - 1]; + } + // async execute + // cb can potentially be undefined but the execute will still go through, so we log but throw an error + // when cb tries to be run + tracer.startWithContext(execFn, stmtOrConn, ...args.slice(0, args.length - 1), resultCB(tracer, cb)); + } +} + +// modify stmt for Dynatrace after a successful prepare +function _DynatraceStmt(stmt, conn, sql) { + const originalExecFn = stmt.exec; + stmt.exec = _ExecuteWrapperFn(stmt, conn, originalExecFn, _dynatraceResultCallback, sql); + const originalExecuteFn = stmt.execute; + stmt.execute = _ExecuteWrapperFn(stmt, conn, originalExecuteFn, _dynatraceResultSetCallback, sql); +} + +function _prepareStmtUsingDynatrace(conn, prepareFn) { + // args = [sql, options, callback] --> options is optional + return function (...args) { + var cb; + if (args.length > 0 && typeof args[args.length - 1] === 'function') { + cb = args[args.length - 1]; + } + var sql = args[0]; + if(typeof(sql) !== 'string') { + sql = ''; // prepare will fail, but need sql for when the error is traced + } + + // same as before, cb can be undefined / not a function but we still log, but throw an error after + prepareFn.call(conn, ...args.slice(0, args.length - 1), dynatrace.api.passContext(function prepare_handler(err, stmt) { + if (err) { + // The prepare failed, so trace the SQL and the error + // We didn't start the tracer yet, so the trace start time will be inaccurate. + const tracer = dynatrace.api.traceSQLDatabaseRequest(conn._dbInfo, {statement: sql}); + tracer.start(function prepare_error_handler() { + tracer.error(err); + tracer.end(cb, err); + }); + } else { + _DynatraceStmt(stmt, conn, sql); + cb(err, stmt); + } + })); + } +} + +function _createDbInfo(destinationInfo) { + const dbInfo = { + name: `SAPHANA${destinationInfo.tenant ? `-${destinationInfo.tenant}` : ''}`, + vendor: dynatrace.sdk.DatabaseVendor.HANADB, + host: destinationInfo.host, + port: Number(destinationInfo.port) + }; + return dbInfo; +} + +function dynatraceConnection(conn, destinationInfo) { + if(dynatrace.api === undefined) { + return conn; + } + const dbInfo = _createDbInfo(destinationInfo); + if(conn._dbInfo) { + // dynatraceConnection has already been called on conn, use new destinationInfo + // in case it changed, but don't wrap conn again + conn._dbInfo = dbInfo; + return conn; + } + conn._dbInfo = dbInfo; + const originalExecFn = conn.exec; + conn.exec = _ExecuteWrapperFn(conn, conn, originalExecFn, _dynatraceResultCallback); + const originalExecuteFn = conn.execute; + conn.execute = _ExecuteWrapperFn(conn, conn, originalExecuteFn, _dynatraceResultSetCallback); + const originalPrepareFn = conn.prepare; + conn.prepare = _prepareStmtUsingDynatrace(conn, originalPrepareFn); + + return conn; +} + +module.exports = { dynatraceConnection, isDynatraceEnabled }; diff --git a/extension/OpenTelemetry.js b/extension/OpenTelemetry.js new file mode 100644 index 0000000..ee1fce7 --- /dev/null +++ b/extension/OpenTelemetry.js @@ -0,0 +1,322 @@ +// Copyright 2013 SAP AG. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http: //www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific +// language governing permissions and limitations under the License. +'use strict'; + +// The hdb driver will automatically use this to add OpenTelemetry +// support when @opentelemetry/api is already installed. + +var ResultSet = require('../lib/protocol/ResultSet'); +var OTel = {}; +try { + // @opentelemetry/api must be installed by the application in order for + // the client to use it. + OTel.API = require('@opentelemetry/api'); + var pjson = require('../package.json'); + OTel.Tracer = OTel.API.trace.getTracer('hdb', pjson.version); + // Future: ideally use SEMATTRS_ values from OTel.semConv = require('@opentelemetry/semantic-conventions') + // Currently do not to avoid the problem of what if @opentelemtry/api is installed but not + // @opentelemetry/semantic-conventions? +} catch (err) { + // If module was not found, do not do anything + if(OTel.Tracer) { + OTel.Tracer = undefined; + } +} + +function isOpenTelemetryEnabled() { + if(OTel.Tracer === undefined) { + return false; + } + const envVar = process.env.HDB_NODEJS_SKIP_OPENTELEMETRY; + if(envVar && envVar != '0' && envVar.toLowerCase() != 'false') { + return false; + } + return true; +} + +function _getSpanNameAndStatus(op, sql, conn) { + // spanName and attributes roughly follow: + // https://github.tools.sap/CPA/telemetry-semantic-conventions/blob/main/docs/database/database-spans.md + // Note the above SAP copy differs from the OpenTelemetry spec of: + // https://opentelemetry.io/docs/specs/semconv/database/database-span + + // The specs says the span name could be ., + // but instead follow CAP and postgresql which roughly use " - " + var spanName = op; + if(sql) { + spanName = op + " - " + sql; + if(spanName.length > 80) { + spanName = spanName.substring(0, 79) + '…'; // based on what CAP used + } + } + // Future: consider using OTel.semConv.SEMATTRS_DB_ values instead of hardcoding attribute names + // FYI CAP and postgresql use net.peer.name, and net.peer.port instead of server.address and server.port + var spanOptions = {kind: OTel.API.SpanKind.CLIENT, + attributes: {'db.system' : 'hanadb', // FYI OT spec says sap.hana + 'server.address': conn._destinationInfo.host,}}; + if(conn._destinationInfo.port) { + try { + spanOptions.attributes['server.port'] = Number(conn._destinationInfo.port); + } catch (err) { + // ignore conversion error + } + } + if(typeof(sql) === 'string') { + // Follow Dynatrace which limits SQL to 1000 characters + var sql_text = sql.length > 1000 ? sql.substring(0, 999) + '…' : sql; + spanOptions.attributes['db.statement'] = sql_text; + } + if(conn._destinationInfo.tenant) { + spanOptions.attributes['db.name'] = conn._destinationInfo.tenant; + } + return {spanName: spanName, spanOptions: spanOptions}; +} + +function _setSpanStatus(span, err) { + if(err) { + span.setStatus(Object.assign({code: OTel.API.SpanStatusCode.ERROR }, err.message ? { message: err.message } : undefined)); + if(err.code) { + // https://opentelemetry.io/docs/specs/semconv/attributes-registry/db/ says this value should be a string + span.setAttribute('db.response.status_code', err.code.toString()); + } + } else { + span.setStatus({code: OTel.API.SpanStatusCode.OK}); + } +} + +function _openTelemetryResultCallback(span, activeCtx, cb) { + return function (err, ...args) { + _setSpanStatus(span, err); + var results = args[0]; + // With DB calls, the first argument can potentially be output parameters + // In that case, we consider the next parameter + if (typeof results === 'object' && results !== null && !Array.isArray(results)) { + results = args[1]; + } + if(results !== undefined) { + // In 0.19.12, results will typically be an array, but for non-batch insert / + // delete / update, results will be a number. This may be changed later to + // match hana-client which returns rows affected as a number even for batches. + span.setAttribute('db.response.returned_rows', (results && results.length) || results); + } + span.end(); + // propagate the active context for async calls + // (otherwise spans started within cb will not know the parent span) + return OTel.API.context.with(activeCtx, function() { + return cb(err, ...args); + }); + }; +} + +function _openTelemetryResultSetCallback(span, activeCtx, cb) { + return function (err, ...args) { + var resultSet = args[0]; + // With DB calls, the first argument can potentially be output parameters + // In that case, we consider the next parameter + if (typeof resultSet === 'object' && resultSet !== null && !(resultSet instanceof ResultSet) + && !Array.isArray(resultSet)) { + resultSet = args[1]; + } + _setSpanStatus(span, err); + if(resultSet instanceof ResultSet) { + const rowCount = resultSet.getRowCount(); + // A negative rowCount means the number of rows is unknown. + // This happens if the client hasn't received the last fetch chunk yet (with default server configuration, + // this happens if the result set is larger than 32 rows) + if(rowCount >= 0) { + span.setAttribute('db.response.returned_rows', rowCount); + } + // modify resultSet for OpenTelemetry after a successful execute + // async methods that do not trace to OpenTelemetry + _setPropagateContextWrapper(resultSet, resultSet.close, "close"); + _setPropagateContextWrapper(resultSet, resultSet.fetch, "fetch"); + } else if (resultSet !== undefined) { + // Same as above, sometimes resultSet can be a number for non-batch insert / delete / update + span.setAttribute('db.response.returned_rows', (resultSet && resultSet.length) || resultSet); + } + span.end(); + // propagate the active context for async calls + // (otherwise spans started within cb will not know the parent span) + return OTel.API.context.with(activeCtx, function() { + return cb(err, ...args); + }); + }; +} + +// Wrapper for thisArg.origFn that we do NOT want to create a span for (eg stmt.drop) +// but we still want to propagate the active context on an async call. +// The method's callback first parameter must the error object. +function _propagateContextWrapperFn(thisArg, origFn) { + // args can end with a callback + return function (...args) { + var cb; + if (args.length > 0 && typeof args[args.length - 1] === 'function') { + cb = args[args.length - 1]; + } + // Sometimes cb can be undefined for disconnect and drop + if(cb) { + const activeCtx = OTel.API.context.active(); + origFn.call(thisArg, ...args.slice(0, args.length - 1), function (...cbArgs) { + // propagate the active context for async calls + // (otherwise spans started within cb will not know the parent span) + OTel.API.context.with(activeCtx, function() { + cb(...cbArgs); + }); + }); + } else { + // No callback so no need to pass context + return origFn.call(thisArg, ...args); + } + } +} + +// thisArg is the class, origFn is the method, fnName is a string (name of method) +function _setPropagateContextWrapper(thisArg, origFn, fnName) { + Object.defineProperty(thisArg, fnName, {value: _propagateContextWrapperFn(thisArg, origFn)}); +} + +// Wrapper for thisArg.origFn that is not a prepare or execute method (eg conn.commit) +// to create a span for the operation. +// The method's callback first parameter must the error object. +function _generalWrapperFn(thisArg, origFn, op, conn) { + // args should end with a callback + return function (...args) { + var cb; + var activeCtx = OTel.API.context.active(); + if (args.length > 0 && typeof args[args.length - 1] === 'function') { + cb = args[args.length - 1]; + } + const {spanName, spanOptions} = _getSpanNameAndStatus(op, undefined, conn); + return OTel.Tracer.startActiveSpan(spanName, spanOptions, function(span) { + // async method call + // cb can potentially be undefined but the function will still go through, so we log but throw an error + // when cb tries to be run + return origFn.call(thisArg, ...args.slice(0, args.length - 1), function (...cbArgs) { + // if cbArgs is empty, cbArgs[0] is undefined, so this is safe + _setSpanStatus(span, cbArgs[0]); + span.end(); + // propagate the active context for async calls + // (otherwise spans started within cb will not know the parent span) + OTel.API.context.with(activeCtx, function() { + cb(...cbArgs); + }); + }); + }); + } +} + +// wrapper for exec and execute +function _executeWrapperFn(thisArg, conn, execFn, op, resultCB, sql) { + // connection exec args = [sql, options, callback] --> options is optional + // stmt exec args = [options, callback] --> options is optional + return function (...args) { + if(thisArg === conn && args.length > 0) { + sql = args[0]; + } + if(typeof(sql) !== 'string') { + sql = ''; // execute will fail, but need sql for when the error is traced + } + var cb; + var activeCtx = OTel.API.context.active(); + if (args.length > 0 && typeof args[args.length - 1] === 'function') { + cb = args[args.length - 1]; + } + const {spanName, spanOptions} = _getSpanNameAndStatus(op, sql, conn); + return OTel.Tracer.startActiveSpan(spanName, spanOptions, function(span) { + // async execute + // cb can potentially be undefined but the execute will still go through, so we log but throw an error + // when cb tries to be run + return execFn.call(thisArg, ...args.slice(0, args.length - 1), resultCB(span, activeCtx, cb)); + }); + } +} + +// modify stmt for OpenTelemetry after a successful prepare +function _modifyStmt(stmt, conn, sql) { + const originalExecFn = stmt.exec; + stmt.exec = _executeWrapperFn(stmt, conn, originalExecFn, "exec", _openTelemetryResultCallback, sql); + + const originalExecuteFn = stmt.execute; + stmt.execute = _executeWrapperFn(stmt, conn, originalExecuteFn, "execute", _openTelemetryResultSetCallback, sql); + + // async methods that do not trace to OpenTelemetry + _setPropagateContextWrapper(stmt, stmt.drop, "drop"); +} + +function _prepareWrapperFn(conn, prepareFn) { + // args = [sql, options, callback] --> options is optional + return function (...args) { + var cb; + var activeCtx = OTel.API.context.active(); + if(args.length > 0 && typeof args[args.length - 1] === 'function') { + cb = args[args.length - 1]; + } + var sql = args[0]; + if(typeof(sql) !== 'string') { + sql = ''; // prepare will fail, but need sql for when the error is traced + } + const {spanName, spanOptions} = _getSpanNameAndStatus("prepare", sql, conn); + return OTel.Tracer.startActiveSpan(spanName, spanOptions, function(span) { + // async prepare + // same as before, cb can be undefined but we still log, but throw an error after + prepareFn.call(conn, ...args.slice(0, args.length - 1), function prepare_handler(err, stmt) { + _setSpanStatus(span, err); + span.end(); + // propagate the active context for async calls + // (otherwise spans started within cb will not know the parent span) + OTel.API.context.with(activeCtx, function() { + if (err) { + cb(err); + } else { + _modifyStmt(stmt, conn, sql); + cb(err, stmt); + } + }); + }); + }); + } +} + +// destinationInfo is an object with host, port and optionally tenant keys +function openTelemetryConnection(conn, destinationInfo) { + if(OTel.Tracer === undefined) { + return conn; + } + if(conn._destinationInfo) { + // openTelemetryConnection has already been called on conn, use new destinationInfo + // in case it changed, but don't wrap conn again + conn._destinationInfo = destinationInfo; + return conn; + } + conn._destinationInfo = destinationInfo; + + const originalExecFn = conn.exec; + conn.exec = _executeWrapperFn(conn, conn, originalExecFn, "exec", _openTelemetryResultCallback); + const originalExecuteFn = conn.execute; + conn.execute = _executeWrapperFn(conn, conn, originalExecuteFn, "execute", _openTelemetryResultSetCallback); + const originalPrepareFn = conn.prepare; + Object.defineProperty(conn, 'prepare', {value: _prepareWrapperFn(conn, originalPrepareFn)}); + + const originalCommitFn = conn.commit; + Object.defineProperty(conn, 'commit', {value: _generalWrapperFn(conn, originalCommitFn, "commit", conn)}); + const originalRollbackFn = conn.rollback; + Object.defineProperty(conn, 'rollback', {value: _generalWrapperFn(conn, originalRollbackFn, "rollback", conn)}); + + // async methods that do not trace to OpenTelemetry + _setPropagateContextWrapper(conn, conn.disconnect, "disconnect"); + + return conn; +} + +module.exports = { openTelemetryConnection, isOpenTelemetryEnabled }; diff --git a/index.js b/index.js index 5061d29..83e18bd 100644 --- a/index.js +++ b/index.js @@ -19,3 +19,5 @@ exports.createClient = lib.createClient; exports.Stringifier = lib.Stringifier; exports.createJSONStringifier = lib.createJSONStringifier; exports.iconv = require('iconv-lite'); +exports.isDynatraceSupported = lib.isDynatraceSupported; +exports.isOpenTelemetrySupported = lib.isOpenTelemetrySupported; diff --git a/lib/Client.js b/lib/Client.js index 3f367cc..e38dd5d 100644 --- a/lib/Client.js +++ b/lib/Client.js @@ -20,6 +20,8 @@ var Connection = protocol.Connection; var Result = protocol.Result; var Statement = protocol.Statement; var ConnectionManager = protocol.ConnectionManager; +var hanaDynatrace = require('../extension/Dynatrace'); +var hanaOpenTelemetry = require('../extension/OpenTelemetry'); module.exports = Client; @@ -31,9 +33,12 @@ function Client(options) { this._settings = util.extend({ fetchSize: 1024, holdCursorsOverCommit: true, - scrollableCursor: true + scrollableCursor: true, + dynatrace: true, + openTelemetry: true }, options); this._settings.useCesu8 = (this._settings.useCesu8 !== false); + normalizeSettings(this._settings); this._connection = this._createConnection(this._settings); } @@ -120,6 +125,10 @@ Client.prototype.connect = function connect(options, cb) { options = {}; } var connectOptions = util.extend({}, this._settings, options); + normalizeSettings(connectOptions); + addDynatraceWrapper(this, {host: this._settings.host, port: this._settings.port, + dynatraceTenant: this._settings.dynatraceTenant, + openTelemetryTenant: this._settings.openTelemetryTenant}, connectOptions); var connManager = new ConnectionManager(connectOptions); // SAML assertion can only be used once @@ -302,6 +311,24 @@ Client.prototype._addListeners = function _addListeners(connection) { connection.on('close', onclose); }; +function normalizeSettings(settings) { + for (var key in settings) { + if (key.toUpperCase() === 'SPATIALTYPES') { + settings['spatialTypes'] = util.getBooleanProperty(settings[key]) ? 1 : 0; + } else if (key.toUpperCase() === 'DYNATRACE') { + var {value, isValid} = util.validateAndGetBoolProperty(settings[key]); + settings['dynatrace'] = isValid ? value : true; + } else if (key.toUpperCase() === 'DYNATRACETENANT') { + settings['dynatraceTenant'] = settings[key]; + } else if (key.toUpperCase() === 'OPENTELEMETRY') { + var {value, isValid} = util.validateAndGetBoolProperty(settings[key]); + settings['openTelemetry'] = isValid ? value : true; + } else if (key.toUpperCase() === 'OPENTELEMETRYTENANT') { + settings['openTelemetryTenant'] = settings[key]; + } + } +} + function normalizeArguments(args, defaults) { var command = args[0]; var options = args[1]; @@ -326,3 +353,16 @@ function normalizeArguments(args, defaults) { } return [command, options, cb]; } + +function addDynatraceWrapper(client, destinationInfo, connectOptions) { + // Prefer OpenTelemetry over Dynatrace if both are enabled + if (hanaOpenTelemetry && hanaOpenTelemetry.isOpenTelemetryEnabled() + && connectOptions.openTelemetry) { + destinationInfo.tenant = destinationInfo.openTelemetryTenant; + hanaOpenTelemetry.openTelemetryConnection(client, destinationInfo); + } else if (hanaDynatrace && hanaDynatrace.isDynatraceEnabled() + && connectOptions.dynatrace) { + destinationInfo.tenant = destinationInfo.dynatraceTenant; + hanaDynatrace.dynatraceConnection(client, destinationInfo); + } +} diff --git a/lib/index.js b/lib/index.js index 5a712ca..815472b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -34,4 +34,8 @@ exports.createJSONStringifier = function createJSONStringifier() { seperator: ',', stringify: JSON.stringify }); -}; \ No newline at end of file +}; + +// External trace support should not change unless there are source code modifications +exports.isDynatraceSupported = true; +exports.isOpenTelemetrySupported = true; diff --git a/lib/protocol/ResultSet.js b/lib/protocol/ResultSet.js index 026f49b..292df79 100644 --- a/lib/protocol/ResultSet.js +++ b/lib/protocol/ResultSet.js @@ -49,6 +49,10 @@ function ResultSet(connection, rsd, options) { readSize: Lob.DEFAULT_READ_SIZE, columnNameProperty: 'columnDisplayName' }, options); + + // Rows in result set from packets so far + this._curRows = rsd.data ? rsd.data.argumentCount : 0; + this._resultSetRowsKnown = rsd.data ? isLast(rsd.data) : false; } ResultSet.create = function createResultSet(connection, rsd, options) { @@ -168,6 +172,13 @@ ResultSet.prototype.getLobColumnIndexes = function getLobColumnIndexes() { return indexes; }; +ResultSet.prototype.getRowCount = function getRowCount() { + if (this._resultSetRowsKnown) { + return this._curRows; + } + return -1; +} + ResultSet.prototype.fetch = function fetch(cb) { var stream = this.createArrayStream(); var collector = new util.stream.Writable({ @@ -340,9 +351,12 @@ function handleData(data) { this.emit('data', data.buffer); } + addResultSetRows.call(this, data); + if (isLast(data)) { this.finished = true; this._running = false; + this._resultSetRowsKnown = true; this.closed = isClosed(data); } @@ -361,6 +375,13 @@ function handleData(data) { } } +function addResultSetRows(data) { + // Stored data is already included in the number of rows + if (this._data !== data) { + this._curRows += data.argumentCount; + } +} + function emitEnd() { /* jshint validthis:true */ debug('emit "end"'); diff --git a/lib/util/index.js b/lib/util/index.js index 870d301..cdbb323 100644 --- a/lib/util/index.js +++ b/lib/util/index.js @@ -275,8 +275,41 @@ function getBooleanProperty(arg) { return false; } else if (arg === 1) { return true; + } else if (arg === true) { + return true; } else { return false; } } exports.getBooleanProperty = getBooleanProperty; + +function validateAndGetBoolProperty(arg) { + var value = false; + var isValid = false; + if (isString(arg)) { + var upper = arg.toUpperCase(); + if (upper === 'TRUE' || upper === 'YES' || upper === 'ON' || upper === '1') { + value = true; + isValid = true; + } else if (upper === 'FALSE' || upper === 'NO' || upper === 'OFF' || upper === '0') { + value = false; + isValid = true; + } + } else if (isNumber(arg)) { + if (arg === 1) { + value = true; + isValid = true; + } else if (arg === 0) { + value = false; + isValid = true; + } + } else if (arg === true) { + value = true; + isValid = true; + } else if (arg === false) { + value = false; + isValid = true; + } + return { value: value, isValid: isValid }; +} +exports.validateAndGetBoolProperty = validateAndGetBoolProperty; diff --git a/test/MockDynatraceSDK/index.js b/test/MockDynatraceSDK/index.js new file mode 100644 index 0000000..3502211 --- /dev/null +++ b/test/MockDynatraceSDK/index.js @@ -0,0 +1,132 @@ +// Copyright 2013 SAP AG. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http: //www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific +// language governing permissions and limitations under the License. +'use strict'; + +// This module is a mock of the @dynatrace/oneagent-sdk module for +// HANA client testing purposes + +// To "install" this mock, copy this index.js and the package.json in the +// same directory to the node_modules/@dynatrace/oneagent-sdk directory. + +// traceData is an object with keys that are numbers (traceNum) and +// values that are objects with keys: +// {dbInfo, sql, startTime, endTime, error, rowsReturned} +var traceData = {}; +var traceEnabled = false; +var lastTraceNum = 0; + +// matching drop-in replacement for a subset of the @dynatrace/oneagent-sdk interface +class DBTracer { + constructor(api, dbinfo, sql, traceNum) { + this.traceNum = traceNum; + if(traceNum) { + traceData[traceNum] = {dbInfo: dbinfo, sql: sql}; + } + } + + // trace start and call cb(...params) + start(cb, ...params) { + if(this.traceNum) { + if(traceData[this.traceNum].startTime) { + console.log("Error: DBTracer.start or startWithContext called more than once"); + } + traceData[this.traceNum].startTime = new Date(); + } + cb(...params); + } + + // trace start and call obj.fn(...params) + startWithContext(fn, obj, ...params) { + if(this.traceNum) { + if(traceData[this.traceNum].startTime) { + console.log("Error: DBTracer.startWithContext or start called more than once"); + } + traceData[this.traceNum].startTime = new Date(); + } + fn.apply(obj, params); + } + + // trace result set data (only interested in prop.rowsReturned) + setResultData(prop) { + if(this.traceNum) { + traceData[this.traceNum].rowsReturned = prop.rowsReturned; + } + } + + // trace error + error(err) { + if(this.traceNum) { + if(traceData[this.traceNum].error) { + console.log("Error: DBTracer.error called more than once"); + } + traceData[this.traceNum].error = err; + } + } + + // end of trace object, so trace end and call cb(...params) if cb is passed in + end(cb, ...params) { + if(this.traceNum) { + if(traceData[this.traceNum].endTime) { + console.log("Error: DBTracer.end called more than once"); + } + traceData[this.traceNum].endTime = new Date(); + } + if(cb) { + cb(...params); + } + } + + // data members: traceNum (undefined if not tracing) +} + +class API { + constructor() { + //console.log('in API constructor'); + } + + traceSQLDatabaseRequest(dbinfo, prop) { + var traceNum; // undefined if trace is not enabled + if(traceEnabled) { + traceNum = ++lastTraceNum; + } + return new DBTracer(this, dbinfo, prop.statement, traceNum); + } + + passContext(fn) { + return fn; + } +} + +exports.createInstance = function() { + return new API(); +} + +exports.DatabaseVendor = {HANADB: 'HANADB'}; + +// functions so tests can get and clear the mocked trace data +exports.enableTrace = function() { + traceEnabled = true; +} +exports.disableTrace = function() { + traceEnabled = false; +} +exports.getTraceData = function() { + return traceData; +} +exports.getLastTraceNum = function() { + return lastTraceNum; +} +exports.clearTraceData = function() { + traceData = {}; +} diff --git a/test/MockDynatraceSDK/package.json b/test/MockDynatraceSDK/package.json new file mode 100644 index 0000000..5e8b196 --- /dev/null +++ b/test/MockDynatraceSDK/package.json @@ -0,0 +1,6 @@ +{ + "author": "SAP SE", + "name": "@dynatrace/oneagent-sdk", + "description": "mock of @dynatrace/oneagent-sdk for HANA Client testing", + "main": "./index.js" +} diff --git a/test/MockOpenTelemetryAPI/index.js b/test/MockOpenTelemetryAPI/index.js new file mode 100644 index 0000000..bfda797 --- /dev/null +++ b/test/MockOpenTelemetryAPI/index.js @@ -0,0 +1,146 @@ +// Copyright 2013 SAP AG. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http: //www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific +// language governing permissions and limitations under the License. +'use strict'; + +// This module is a mock of the @opentelemetry/api module for +// HANA client testing purposes + +// To "install" this mock, copy this index.js and the package.json in the +// same directory to the node_modules/@opentelemetry/api directory. +// OR run `npm install /path/to/MockOpenTelemetryAPI` + +// traceData is an object with keys that are numbers (traceNum) and +// values that are objects with keys: +// {spanName, dbInfo, sql, startTime, endTime, error, rowsReturned} +// (traceData values are defined this way so the contents are the same as for MockDynatraceSDK) +var traceData = {}; +var traceEnabled = false; +var lastTraceNum = 0; + + +// matching drop-in replacement for a subset of the @opentelemetry/api interface +const StatusCodeEnum = {OK: 1, ERROR: 2}; +const ActiveContext = "Active Context"; + +class Span { + constructor(name, options, traceNum) { + this.traceNum = traceNum; + if(traceNum) { + var dbInfo = {host: options.attributes['server.address'], + port: options.attributes['server.port'], + name: 'SAPHANA', + vendor: 'HANADB'}; + if(options.attributes['db.name']) { + dbInfo.name = 'SAPHANA-' + options.attributes['db.name']; + } + // TODO consider validating the other attributes + // console.log("spanName: " + name + ", options: " + JSON.stringify(options)); // for debugging + traceData[traceNum] = {spanName: name, dbInfo: dbInfo, sql: options.attributes['db.statement'], startTime: new Date()}; + } + } + + setStatus(status) { + if(this.traceNum && status.code == StatusCodeEnum.ERROR) { + var err = {statusCode: status.code}; + if(status.message) { + err.message = status.message; + } + traceData[this.traceNum].error = err; + } + } + + // trace returned_rows and status_code + setAttribute(name, value) { + if(this.traceNum) { + if(name === 'db.response.returned_rows') { + traceData[this.traceNum].rowsReturned = value; + } else if(name === 'db.response.status_code' && traceData[this.traceNum].error) { + traceData[this.traceNum].error.code = Number(value); + } else { + console.log("Error: Span.setAttribute called with unexpected name: " + name); + } + } + } + + end() { + if(this.traceNum) { + if(traceData[this.traceNum].endTime) { + console.log("Error: Span.end called more than once"); + } + traceData[this.traceNum].endTime = new Date(); + } + } + + // data members: traceNum (undefined if not tracing) +} + +class Tracer { + constructor(name, version) { + // console.log('in Tracer constructor name: ' + name + ', version:' + version); + } + + startSpan(name, options) { + var traceNum; // undefined if trace is not enabled + if(traceEnabled) { + traceNum = ++lastTraceNum; + } + return new Span(name, options, traceNum); + } + + startActiveSpan(name, options, fn) { + var traceNum; // undefined if trace is not enabled + if(traceEnabled) { + traceNum = ++lastTraceNum; + } + return fn(new Span(name, options, traceNum)); + } +} + +// @opentelemetry/api interface replacement +exports.trace = { + getTracer: function getTracer(name, version) { + return new Tracer(name, version); + } +} +exports.context = { + active: function () { + return ActiveContext; + }, + + with: function (context, fn) { + if(context !== ActiveContext) { + console.log("Error: context.with called with unexpected context"); + } + return fn(); + } +} +exports.SpanKind = {CLIENT: 2}; +exports.SpanStatusCode = StatusCodeEnum; + +// functions so tests can get and clear the mocked trace data +exports.enableTrace = function() { + traceEnabled = true; +} +exports.disableTrace = function() { + traceEnabled = false; +} +exports.getTraceData = function() { + return traceData; +} +exports.getLastTraceNum = function() { + return lastTraceNum; +} +exports.clearTraceData = function() { + traceData = {}; +} diff --git a/test/MockOpenTelemetryAPI/package.json b/test/MockOpenTelemetryAPI/package.json new file mode 100644 index 0000000..3415ee7 --- /dev/null +++ b/test/MockOpenTelemetryAPI/package.json @@ -0,0 +1,6 @@ +{ + "author": "SAP SE", + "name": "@opentelemetry/api", + "description": "mock of @opentelemetry/api for HANA Client testing", + "main": "./index.js" +} diff --git a/test/acceptance/db.ExtTrace.js b/test/acceptance/db.ExtTrace.js new file mode 100644 index 0000000..1ce0afe --- /dev/null +++ b/test/acceptance/db.ExtTrace.js @@ -0,0 +1,799 @@ +// Copyright 2013 SAP AG. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http: //www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +// either express or implied. See the License for the specific +// language governing permissions and limitations under the License. +'use strict'; +/* jshint undef:false, expr:true */ + +var async = require('async'); +var RemoteDB = require('../db/RemoteDB'); +var util = require('../../lib/util'); +// Both Dynatrace and OpenTelemetry are considered External tracers (ExtTrace) +var hanaDynatrace = require('../../extension/Dynatrace'); +var dynatraceSDK; // either the real @dynatrace/oneagent-sdk Dynatrace SDK or the mock one +var hanaOpenTel = require('../../extension/OpenTelemetry'); +var openTelAPI; // either the real @opentelemetry/api OpenTelemetry API or the mock one +var mockExtTrace; // either mock @dynatrace/oneagent-sdk or mock @opentelemetry/api +var http, server, request; +var db, isRemoteDB; +const openTelTestVar = process.env.ENABLE_NODE_OPENTEL_TESTS; +const testOpenTel = openTelTestVar && openTelTestVar != '0' && openTelTestVar.toLowerCase() != 'false'; +try { + if (testOpenTel) { + // Avoid getting dynatrace tracing (should be redundant since OpenTelemetry is preferred + // over dynatrace) + db = require('../db')({dynatrace: false}); + openTelAPI = require('@opentelemetry/api'); + if (openTelAPI.getTraceData !== undefined) { + // Using mock @opentelemetry/api + mockExtTrace = openTelAPI; + } else { + // Using real @opentelemetry/api, so setup web request + http = require('http'); + } + } else { + dynatraceSDK = require('@dynatrace/oneagent-sdk'); + // When testing dynatrace, disable open telemetry so that the tracing will go to dynatrace + db = require('../db')({openTelemetry: false}); + if (dynatraceSDK.getTraceData !== undefined) { + // Using mock @dynatrace/oneagent-sdk + mockExtTrace = dynatraceSDK; + } else { + // Using real @dynatrace/oneagent-sdk, so setup web request + http = require('http'); + } + } + isRemoteDB = db instanceof RemoteDB; +} catch (err) { + // No @dynatrace/oneagent-sdk / @opentelemetry/api, skip this test, see + // MockDynatraceSDK / MockOpenTelemetryAPI to "install" the mock to run + // these tests +} + +var describeExtTrace = (db instanceof RemoteDB && ((!testOpenTel && dynatraceSDK !== undefined) || + (testOpenTel && openTelAPI !== undefined))) ? describe : describe.skip; + +function isMockExtTraceEnabled() { + if (testOpenTel) { + return mockExtTrace && hanaOpenTel.isOpenTelemetryEnabled(); + } else { + return mockExtTrace && hanaDynatrace.isDynatraceEnabled(); + } +} + +describeExtTrace('db', function () { + before(function (done) { + if (isMockExtTraceEnabled()) { + mockExtTrace.enableTrace(); + } + if (mockExtTrace) { + db.init.bind(db)(done); + } else { + // Real external trace, create an inbound web request + server = http.createServer(function onRequest(req, res) { + request = res; + db.init.bind(db)(done); + }).listen(8001).on("listening", () => http.get("http://localhost:" + server.address().port));; + } + }); + after(function (done) { + if (isMockExtTraceEnabled()) { + mockExtTrace.disableTrace(); + } + if (mockExtTrace) { + db.end.bind(db)(done); + } else { + this.timeout(15000); + // Real external trace, stop the web request + request.end(); + // When using real external trace, give some time for the trace backend + // to log results + setTimeout(function () { + server.close(); + db.end.bind(db)(done); + }, 10000); + } + }); + // If the db is undefined, we will skip the tests + var client = db ? db.client : undefined; + + describeExtTrace('external trace', function () { + it('should trace a prepared statement exec', function (done) { + var sql = 'SELECT 1 FROM DUMMY'; + var destInfo = getDestInfoForExtTrace(); + client.prepare(sql, function (err, stmt) { + if (err) done(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + stmt.exec([], function (err, rows) { + if (err) done(err); + rows.should.have.length(1); + rows[0]['1'].should.equal(1); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, 1); + cleanup(stmt, done); + }); + }); + }); + + it('should trace a client exec', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT TOP 10 * FROM OBJECTS'; + client.exec(sql, function (err, rows) { + if (err) done(err); + rows.should.have.length(10); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, 10); + done(); + }); + }); + + it('should trace exec / prepare errors', function (done) { + var destInfo = getDestInfoForExtTrace(); + function testExecSqlSyntaxError(input) { + return function execError(cb) { + client.exec(input, function (err) { + err.should.be.an.instanceOf(Error); + // When the sql input is not a string, we log an empty string for the sql in the external trace + verifyOpenTelSpan((typeof input === 'string' ? 'exec - ' + input : 'exec')); + verifyExtTraceData(destInfo, typeof input === 'string' ? input : '', undefined, { code: 257 }); + cb(); + }) + } + } + function testPrepareSqlSyntaxError(input) { + return function prepareError(cb) { + client.prepare(input, function (err, statement) { + (!!statement).should.not.be.ok; + err.should.be.an.instanceOf(Error); + verifyOpenTelSpan((typeof input === 'string' ? 'prepare - ' + input : 'prepare')); + verifyExtTraceData(destInfo, typeof input === 'string' ? input : '', undefined, { code: 257 }); + cb(); + }) + } + } + function castError(cb) { + var sql = 'SELECT CAST(? AS INT) FROM DUMMY'; + client.prepare(sql, function (err, statement) { + if (err) cb(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + // Check server version since HANA 2 SPS05 gives a server error + var version = db.getHANAFullVersion(); + var versionSplit = version.split("."); + var major = Number(versionSplit[0]); + var revision = Number(versionSplit[2]); + if (!(major == 2 && revision < 70)) { + statement.exec(['string to int cast'], function (err) { + err.should.be.an.instanceOf(Error); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, undefined, { + message: 'Cannot set parameter at row: 1. Wrong input for INT type' + }); + cleanup(statement, cb); + }); + } else { + // Skip part of test on pre HANA2sp7 + cb(); + } + }); + } + + async.series([testExecSqlSyntaxError('SELECT 2 SYNTAX ERROR'), testExecSqlSyntaxError([2]), + testExecSqlSyntaxError('SELECT * FROM /* SYNTAX ERROR */'), + testPrepareSqlSyntaxError('SELECT 3 SYNTAX ERROR'), testPrepareSqlSyntaxError([3]), + testPrepareSqlSyntaxError('SELECT /* SYNTAX ERROR */ FROM DUMMY'), castError], done); + }); + + it('should trace a statement exec unbound parameters error', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT ? FROM DUMMY'; + client.prepare(sql, function (err, stmt) { + if (err) done(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + stmt.exec([], function (err, rows) { + err.should.be.an.instanceOf(Error); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, undefined, { message: "Unbound parameters found." }); + cleanup(stmt, done); + }); + }); + }); + + it('should time an exec', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT 2 FROM DUMMY'; + client.prepare(sql, function (err, stmt) { + if (err) done(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + var beforeExecTime = new Date(); + stmt.exec([], function (err, rows) { + var afterExecTime = new Date(); + if (err) done(err); + rows.should.have.length(1); + rows[0]['2'].should.equal(2); + var elapsedExecTime = afterExecTime - beforeExecTime; + verifyExtTraceRequestTime(Math.max(0, elapsedExecTime - 1000), elapsedExecTime); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, 1); + cleanup(stmt, done); + }) + }); + }); + + it('should time a 2 second procedure', function (done) { + this.timeout(3000); + var destInfo = getDestInfoForExtTrace(); + var sql = 'DO BEGIN CALL SQLSCRIPT_SYNC:SLEEP_SECONDS(2); END'; + client.prepare(sql, function (err, stmt) { + if (err) done(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + var beforeExecTime = new Date(); + stmt.exec([], function (err, params) { + var afterExecTime = new Date(); + if (err) done(err); + var elapsedExecTime = afterExecTime - beforeExecTime; + elapsedExecTime.should.be.aboveOrEqual(1900); + verifyExtTraceRequestTime(Math.max(1900, elapsedExecTime - 1000), elapsedExecTime); + verifyOpenTelSpan('exec - ' + sql); + // This db call does not return any rows, so the behaviour is to not log any rowsReturned + verifyExtTraceData(destInfo, sql, undefined); + cleanup(stmt, done); + }); + }); + }); + + it('should trace multiple exec with a statement', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT ? FROM DUMMY'; + var statement; + function prepare (cb) { + client.prepare(sql, function (err, ps) { + if (err) done(err); + statement = ps; + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + cb(err); + }); + } + function testExecStatement(input) { + return function execStatement(cb) { + var beforeExecTime = new Date(); + statement.exec(input, function (err, rows) { + var afterExecTime = new Date(); + if (err) done(err); + var elapsedExecTime = afterExecTime - beforeExecTime; + verifyExtTraceRequestTime(Math.max(0, elapsedExecTime - 1000), elapsedExecTime); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, 1); + rows.should.have.length(1); + rows[0][':1'].should.equal(input[0]); + cb(); + }); + }; + } + // Test that commits are traced for OpenTelemetry, since auto commit is on + // the commit has no effect + function commit(cb) { + client.commit(function (err) { + if (err) done(err); + verifyOpenTelData('commit', destInfo); + cb(); + }); + } + function dropStatement(cb) { + cleanup(statement, cb); + } + + async.waterfall([prepare, testExecStatement(['1']), testExecStatement(['2']), commit, dropStatement], done); + }); + + it('should trace a client exec with 2k length sql', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = "SELECT '" + 'A'.repeat(2000) + "' FROM DUMMY"; + client.exec(sql, function (err, rows) { + if (err) done(err); + rows.should.have.length(1); + // Our extension/OpenTelemetry.js truncates the span name to 80 characters + // and sql to 1000 chars. + // Our extension/Dynatrace.js does not truncate sql since Dynatrace itself + // sanitizes SQL and limits it to 1000 characters. + var sqlTrunc1000 = sql.substring(0, 999) + '…'; + var spanNameTrunc80 = ("exec - " + sql).substring(0, 79) + '…'; + verifyOpenTelSpan(spanNameTrunc80); + verifyExtTraceData(destInfo, testOpenTel ? sqlTrunc1000 : sql, 1); + done(); + }); + }); + + it('should trace a client execute with a result set with 10 rows', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT TOP 10 * FROM OBJECTS'; + client.execute(sql, function (err, rs) { + if (err) done(err); + verifyOpenTelSpan('execute - ' + sql); + verifyExtTraceData(destInfo, sql, 10); + rs.fetch(function (err, rows) { + if (err) done(err); + rows.should.have.length(10); + if (!rs.closed) { + rs.close(); + } + done(); + }); + }); + }); + + function testExtTraceExecuteNRows(numRows, cb) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT TOP ? * FROM OBJECTS'; + client.prepare(sql, function (err, stmt) { + if (err) cb(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + stmt.execute([numRows], function (err, rs) { + if (err) cb(err); + // FYI, we define the ExtTrace end as when the execQuery callback is called + // If there are more than 32 rows, we don't know the number of rows returned + // because we only know the actual number of rows when we've received the last + // fetch chunk. + const expectedNumRows = (numRows > 32) ? undefined : numRows; + verifyOpenTelSpan('execute - ' + sql); + verifyExtTraceData(destInfo, sql, expectedNumRows); + rs.fetch(function (err, rows) { + if (err) cb(err); + rows.should.have.length(numRows); + if (!rs.closed) { + rs.close(); + } + cleanup(stmt, cb); + }); + }) + }); + } + + it('should trace a statement execute with a result set with 1 row', function (done) { + testExtTraceExecuteNRows(1, done); + }); + it('should trace a statement execute with a result set with 32 rows', function (done) { + testExtTraceExecuteNRows(32, done); + }); + it('should trace a statement execute with a result set with 33 rows', function (done) { + testExtTraceExecuteNRows(33, done); + }); + it('should trace a statement execute with a result set with 0 rows', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT 3 FROM DUMMY WHERE 1 = 0'; + client.prepare(sql, function (err, stmt) { + if (err) done(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + stmt.execute([], function (err, rs) { + if (err) done(err); + verifyOpenTelSpan('execute - ' + sql); + verifyExtTraceData(destInfo, sql, 0); + rs.fetch(function (err, rows) { + if (err) done(err); + rows.should.have.length(0); + if (!rs.closed) { + rs.close(); + } + cleanup(stmt, done); + }); + }) + }); + }); + + it('should trace multiple execute with a statement', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT 1 FROM DUMMY WHERE 1 = ?'; + var statement; + function clearTraceData(cb) { + if (isMockExtTraceEnabled()) { + mockExtTrace.clearTraceData(); + mockExtTrace.getTraceData().should.be.empty; + } + cb(); + } + function prepare (cb) { + client.prepare(sql, function (err, ps) { + if (err) done(err); + statement = ps; + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + cb(err); + }); + } + function testExecuteStatement(input, expectedRows) { + return function executeStatement(cb) { + var beforeExecTime = new Date(); + statement.execute(input, function (err, rs) { + var afterExecTime = new Date(); + if (err) done(err); + var elapsedExecTime = afterExecTime - beforeExecTime; + if (isMockExtTraceEnabled()) { + Object.keys(mockExtTrace.getTraceData()).should.have.length(1); + } + verifyExtTraceRequestTime(Math.max(0, elapsedExecTime - 1000), elapsedExecTime); + verifyOpenTelSpan('execute - ' + sql); + verifyExtTraceData(destInfo, sql, expectedRows.length); + rs.fetch(function (err, rows) { + rows.should.eql(expectedRows); + if (!rs.closed) { + rs.close(); + } + cb(); + }) + }); + }; + } + // Test that rollbacks are traced for OpenTelemetry, since auto commit is on + // the rollback has no effect + function rollback(cb) { + client.rollback(function (err) { + if (err) done(err); + verifyOpenTelData('rollback', destInfo); + cb(); + }); + } + function dropStatement(cb) { + cleanup(statement, cb); + } + + async.waterfall([clearTraceData, prepare, testExecuteStatement(['1'], [{ '1': 1 }]), + testExecuteStatement(['2'], []), rollback, dropStatement], done); + }); + + it('should trace the first rows result in a DB call', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = `DO (IN P1 INTEGER => ?, OUT P2 INTEGER => ?, OUT P3 INTEGER => ?) + BEGIN + P2 = :P1 + 1; + P3 = :P1 + 2; + SELECT TOP 10 * FROM OBJECTS; + SELECT 'A' AS A FROM DUMMY; + END`; + client.prepare(sql, function (err, stmt) { + if (err) done(err); + // Our extension/OpenTelemetry.js truncates the span name to 80 characters + var spanPrepName80 = ("prepare - " + sql).substring(0, 79) + '…'; + verifyOpenTelData(spanPrepName80, destInfo, sql); + stmt.exec([1], function (err, params, objRows, dummyRows) { + if (err) done(err); + params.should.eql({ P2: 2, P3: 3 }); + objRows.should.have.length(10); + dummyRows.should.have.length(1); + var spanExecName80 = ("exec - " + sql).substring(0, 79) + '…'; + verifyOpenTelSpan(spanExecName80); + // The current behaviour is that the rows returned is traced as the length + // of the first rows result (not object parameters) + verifyExtTraceData(destInfo, sql, 10); + cleanup(stmt, done); + }); + }); + }); + + it('should disable Dynatrace / OpenTelemetry with environment variable', function (done) { + if (testOpenTel) { + var skipOpenTelemetry = process.env.HDB_NODEJS_SKIP_OPENTELEMETRY; + process.env.HDB_NODEJS_SKIP_OPENTELEMETRY = true; + hanaOpenTel.isOpenTelemetryEnabled().should.equal(false); + if (skipOpenTelemetry) { + process.env.HDB_NODEJS_SKIP_OPENTELEMETRY = skipOpenTelemetry; + } else { + delete process.env.HDB_NODEJS_SKIP_OPENTELEMETRY; + } + } else { + var skipDynatrace = process.env.HDB_NODEJS_SKIP_DYNATRACE; + process.env.HDB_NODEJS_SKIP_DYNATRACE = true; + hanaDynatrace.isDynatraceEnabled().should.equal(false); + if (skipDynatrace) { + process.env.HDB_NODEJS_SKIP_DYNATRACE = skipDynatrace; + } else { + delete process.env.HDB_NODEJS_SKIP_DYNATRACE; + } + } + done(); + }); + + it('should disable Dynatrace / OpenTelemetry with dynatrace / openTelemetry connect ' + + 'option (only tested on mock)', function (done) { + if (!isMockExtTraceEnabled()) { + // The disabling of Dynatrace / OpenTelemetry using the dynatrace / openTelemetry connect + // option can only be validated when Dynatrace / OpenTelemetry is enabled (no skip env + // and oneagent-sdk / opentelemetry/api exists) and we are using the mock + this.skip(); + } else { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT 1 FROM DUMMY'; + mockExtTrace.clearTraceData(); + var nonExtTraceDB = require('../db')({dynatrace: false, openTelemetry: false}); + nonExtTraceDB.init(function (err) { + if (err) done(err); + var nonExtTraceClient = nonExtTraceDB.client; + nonExtTraceClient.exec(sql, function (err, rows) { + if (err) done(err); + rows.should.have.length(1); + Object.keys(mockExtTrace.getTraceData()).length.should.equal(0); + + // Manually re-enable the Dynatrace / OpenTelemetry extension + if (testOpenTel) { + hanaOpenTel.openTelemetryConnection(nonExtTraceClient, destInfo); + } else { + hanaDynatrace.dynatraceConnection(nonExtTraceClient, destInfo); + } + nonExtTraceClient.exec(sql, function (err, rows) { + if (err) done(err); + Object.keys(mockExtTrace.getTraceData()).length.should.equal(1); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, 1); + nonExtTraceDB.end(done); + }); + }); + }); + } + }); + + it('should configure a dynatraceTenant / openTelemetryTenant option', function (done) { + var tenantName = 'ExternalTraceTenant'; + var destInfo = getDestInfoForExtTrace(tenantName); + var sql = 'SELECT 1 FROM DUMMY'; + if (isMockExtTraceEnabled()) { + mockExtTrace.clearTraceData(); + } + var tenantDB; + if (testOpenTel) { + tenantDB = require('../db')({openTelemetry: true}); + // The db module will save settings, so we set the tenant settings directly in the client + // as they cannot be easily rewritten once saved + tenantDB.client.set('openTelemetryTenant', tenantName); + } else { + tenantDB = require('../db')({dynatrace: true}); + tenantDB.client.set('dynatraceTenant', tenantName); + } + tenantDB.init(function (err) { + if (err) done(err); + var tenantClient = tenantDB.client; + tenantClient.exec(sql, function (err, rows) { + if (err) done(err); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, 1); + tenantDB.end(done); + }); + }); + }); + + it('should prefer OpenTelemetry over Dynatrace', function (done) { + if (!(isMockExtTraceEnabled() && hanaOpenTel.isOpenTelemetryEnabled() + && hanaDynatrace.isDynatraceEnabled())) { + // Preferring OpenTelemetry can only be tested when OpenTelemetry and Dynatrace + // are enabled and we are using the mock of Dynatrace or OpenTelemetry + this.skip(); + } else { + var destInfo = getDestInfoForExtTrace(); + var sql = 'SELECT 1 FROM DUMMY'; + var noPreferenceDB = require('../db')({dynatrace: true, openTelemetry: true}); + noPreferenceDB.init(function (err) { + if (err) done(err); + var noPrefClient = noPreferenceDB.client; + noPrefClient.exec(sql, function (err, rows) { + if (err) done(err); + rows.should.have.length(1); + if (testOpenTel) { + // ensure we traced to OpenTelemetry even though Dynatrace was avaliable + Object.keys(mockExtTrace.getTraceData()).length.should.equal(1); + verifyOpenTelData('exec - ' + sql, destInfo, sql, 1); + } else { + // since OpenTelemetry was avaliable, we should have no Dynatrace data + Object.keys(mockExtTrace.getTraceData()).length.should.equal(0); + } + noPreferenceDB.end(done); + }); + }); + } + }); + + describeExtTrace('using table', function () { + beforeEach(function (done) { + if (isRemoteDB) { + db.createTable.bind(db)('TEST_EXT_TRACE', ['ID INT UNIQUE NOT NULL'], null, done); + } else { + this.skip(); + done(); + } + }); + afterEach(function (done) { + if (isRemoteDB) { + db.dropTable.bind(db)('TEST_EXT_TRACE', done); + } else { + done(); + } + }); + + it('should trace a client insert', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'INSERT INTO TEST_EXT_TRACE VALUES(1)'; + client.exec(sql, function (err, rowsAffected) { + if (err) done(err); + rowsAffected.should.equal(1); + verifyOpenTelSpan('exec - ' + sql); + // Trace rows affected as rows returned + verifyExtTraceData(destInfo, sql, 1); + client.exec('SELECT COUNT(*) FROM TEST_EXT_TRACE', {rowsAsArray: true}, function (err, rows) { + if (err) done(err); + rows[0][0].should.equal(1); + done(); + }); + }); + }); + + it('should trace a prepared statement delete', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'DELETE FROM TEST_EXT_TRACE'; + client.exec('INSERT INTO TEST_EXT_TRACE VALUES(1)', function (err, rowsAffected) { + if (err) done(err); + client.exec('INSERT INTO TEST_EXT_TRACE VALUES(2)', function (err, rowsAffected) { + if (err) done(err); + client.prepare(sql, function (err, stmt) { + if (err) done(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + stmt.exec([], function (err, rowsAffected) { + rowsAffected.should.equal(2); + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, 2); + client.exec('SELECT COUNT(*) FROM TEST_EXT_TRACE', {rowsAsArray: true}, function (err, rows) { + if (err) done(err); + rows[0][0].should.equal(0); + cleanup(stmt, done); + }); + }); + }); + }); + }); + }); + + function testStatementBatchInsert(useExec, done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'INSERT INTO TEST_EXT_TRACE VALUES(?)'; + var statement; + + function validateInsert(err, rowsAffected) { + if (err) done(err); + rowsAffected.should.eql([1, 1, 1, 1]); + verifyOpenTelSpan((useExec ? 'exec' : 'execute') + ' - ' + sql); + verifyExtTraceData(destInfo, sql, 4); + client.exec('SELECT COUNT(*) FROM TEST_EXT_TRACE', {rowsAsArray: true}, function (err, rows) { + if (err) done(err); + rows[0][0].should.equal(4); + cleanup(statement, done); + }); + } + + client.prepare(sql, function (err, stmt) { + if (err) done(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + statement = stmt; + if (useExec) { + statement.exec([[1], [2], [3], [4]], validateInsert); + } else { + statement.execute([[1], [2], [3], [4]], validateInsert); + } + }); + } + + it('should trace a statement batch exec', function (done) { + testStatementBatchInsert(true, done); + }); + + it('should trace a statement batch execute', function (done) { + testStatementBatchInsert(false, done); + }); + + it('should trace a statement batch exec error', function (done) { + var destInfo = getDestInfoForExtTrace(); + var sql = 'INSERT INTO TEST_EXT_TRACE VALUES(?)'; + client.prepare(sql, function (err, stmt) { + if (err) done(err); + verifyOpenTelData('prepare - ' + sql, destInfo, sql); + stmt.exec([['string going to int column'], ['2']], function (err, rowsAffected) { + err.should.be.an.instanceOf(Error); + (!!rowsAffected).should.not.be.ok; + verifyOpenTelSpan('exec - ' + sql); + verifyExtTraceData(destInfo, sql, undefined, { + message: 'Cannot set parameter at row: 1. Wrong input for INT type' + }); + cleanup(stmt, done); + }); + }); + }); + }); + }); + + function getDestInfoForExtTrace(tenant) { + return { host: client.get('host'), port: client.get('port'), tenant: tenant }; + } +}); + +function verifyExtTraceData(destInfo, sql, expectedRowsReturned, expectedError) { + if(isMockExtTraceEnabled()) { // Only validate the data on the mock + var got = mockExtTrace.getTraceData()[mockExtTrace.getLastTraceNum()]; + got.should.not.be.undefined; + if(got) { + if (expectedError) { + (!!got.error).should.be.ok; + if (expectedError.code) { + got.error.code.should.equal(expectedError.code); + } + if (expectedError.message) { + got.error.message.should.equal(expectedError.message); + } + } else { + (!!got.error).should.be.not.ok; + } + got.dbInfo.name.should.eql(destInfo.tenant ? 'SAPHANA-' + destInfo.tenant : 'SAPHANA'); + got.dbInfo.vendor.should.eql('HANADB'); + got.dbInfo.host.should.eql(destInfo.host); + got.dbInfo.port.should.eql(Number(destInfo.port)); + if (sql !== undefined) { + got.sql.should.eql(sql); + } else { + (got.sql === undefined).should.be.ok; + } + + got.startTime.should.not.be.undefined; + if (expectedRowsReturned !== undefined) { + got.rowsReturned.should.equal(expectedRowsReturned); + } else { + (got.rowsReturned === undefined).should.be.ok; + } + got.endTime.should.not.be.undefined; + got.endTime.should.be.aboveOrEqual(got.startTime); + } + mockExtTrace.clearTraceData(); + } +} + +// must be called before verifyExtTraceData since that clears the trace data +function verifyExtTraceRequestTime(minAllowedMS, maxAllowedMS) { + if(isMockExtTraceEnabled()) { + var got = mockExtTrace.getTraceData()[mockExtTrace.getLastTraceNum()]; + got.should.not.be.undefined; + if(got) { + var gotElapsedTime = got.endTime - got.startTime; + gotElapsedTime.should.be.aboveOrEqual(minAllowedMS); + gotElapsedTime.should.be.belowOrEqual(maxAllowedMS); + } + } +} + +// Verifies span name when open telemetry is running, does not clear trace data +function verifyOpenTelSpan(spanName) { + // Only validate the data on the mock opentelemetry + if(testOpenTel && isMockExtTraceEnabled()) { + var got = mockExtTrace.getTraceData()[mockExtTrace.getLastTraceNum()]; + got.should.not.be.undefined; + if(got) { + got.spanName.should.eql(spanName); + } + } +} + +// Verifies all data (including span name) when open telemetry is running, and +// does clear trace data +function verifyOpenTelData(spanName, destInfo, sql, expectedRowsReturned, expectedError) { + // Only validate the data on the mock opentelemetry + if(testOpenTel && isMockExtTraceEnabled()) { + verifyOpenTelSpan(spanName); + verifyExtTraceData(destInfo, sql, expectedRowsReturned, expectedError); + } +} + +function cleanup(stmt, cb) { + stmt.drop(function (err) { + // ignore error + cb(); + }) +} \ No newline at end of file diff --git a/test/lib.ResultSet.js b/test/lib.ResultSet.js index e81f512..4314343 100644 --- a/test/lib.ResultSet.js +++ b/test/lib.ResultSet.js @@ -254,6 +254,63 @@ function createResultSetWithoutLob(options) { return createResultSet(rsd, chunks, options); } +function createOneChunkResultSet(options) { + var rsd = { + id: new Buffer([1, 0, 0, 0, 0, 0, 0, 0]), + metadata: [{ + dataType: TypeCode.SMALLINT, + columnDisplayName: 'SMALLINT' + }], + data: { + argumentCount: 5, + attributes: ResultSetAttributes.LAST | + ResultSetAttributes.CLOSED, + kind: 5, + buffer: Buffer.concat([ + writeInt(1), writeInt(2), writeInt(3), + writeInt(4), writeInt(5) + ]) + } + }; + return createResultSet(rsd, undefined, options); +} + +function createResultSetWithRepeats(options) { + // Buffer with small int encodings for 1 - 32 + var buffer32 = Buffer.concat(Array.from(Array(32).keys()).map(function (value) { + return writeInt(value + 1); + })); + var rsd = { + id: new Buffer([1, 0, 0, 0, 0, 0, 0, 0]), + metadata: [{ + dataType: TypeCode.SMALLINT, + columnDisplayName: 'SMALLINT' + }], + data: { + argumentCount: 32, + attributes: 0, + buffer: buffer32 + } + }; + var chunks = [{ + argumentCount: 32, + attributes: 0, + buffer: buffer32 + }, { + argumentCount: 32, + attributes: 0, + buffer: buffer32 + }, { + argumentCount: 4, + attributes: ResultSetAttributes.LAST | + ResultSetAttributes.CLOSED, + buffer: Buffer.concat([ + writeInt(97), writeInt(98), writeInt(99), writeInt(100) + ]) + }]; + return createResultSet(rsd, chunks, options); +} + describe('Lib', function () { describe('#ResultSet', function () { @@ -549,5 +606,48 @@ describe('Lib', function () { }); }); + it('should get row count without fetch all for one chunk result set', function (done) { + var rs = createOneChunkResultSet(); + rs.getRowCount().should.equal(5); + rs.fetch(function (err, rows) { + if (err) done(err); + rows.should.eql([{ + SMALLINT: 1, + }, { + SMALLINT: 2, + }, { + SMALLINT: 3, + }, { + SMALLINT: 4, + }, { + SMALLINT: 5, + }]); + rs.finished.should.be.true; + rs.closed.should.be.true; + done(); + }); + }); + + it('should not get row count until last chunk is received', function (done) { + var rs = createResultSetWithRepeats(); + rs.getRowCount().should.equal(-1); + rs.fetch(function (err, rows) { + if (err) done(err); + var expectedRows = []; + for (var i = 0; i < 100; i++) { + if (i >= 96) { + expectedRows.push({SMALLINT: i + 1}); + } else { + expectedRows.push({SMALLINT: (i % 32) + 1}); + } + } + rows.should.eql(expectedRows); + rs.finished.should.be.true; + rs.closed.should.be.true; + rs.getRowCount().should.equal(100); + done(); + }); + }); + }); });