UNPKG

swagger-stats

Version:

API Telemetry and APM. Trace API calls and Monitor API performance, health and usage statistics in Node.js Microservices, based on express routes and Swagger (Open API) specification

513 lines (397 loc) 18.1 kB
/** * Created by sv2 on 2/18/17. * API Statistics */ 'use strict'; const util = require('util'); const { pathToRegexp } = require('path-to-regexp'); const debug = require('debug')('sws:apistats'); const promClient = require("prom-client"); const swsSettings = require('./swssettings'); const swsMetrics = require('./swsmetrics'); const swsUtil = require('./swsUtil'); const swsReqResStats = require('./swsReqResStats'); const swsBucketStats = require('./swsBucketStats'); // API Statistics // Stores Definition of API based on Swagger Spec // Stores API Statistics, for both Swagger spec-based API, as well as for detected Express APIs (route.path) // Stores Detailed Stats for each API request function swsAPIStats() { // Options this.options = null; // API Base path per swagger spec this.basePath = '/'; // Array of possible API path matches, populated based on Swagger spec // Contains regex to match URI to Swagger path this.apiMatchIndex = {}; // API definition - entry per API request from swagger spec // Stores attributes of known Swagger APIs - description, summary, tags, parameters this.apidefs = {}; // API statistics - entry per API request from swagger // Paths not covered by swagger will be added on demand as used this.apistats = {}; // Detailed API stats // TODO Consider: individual timelines (?), parameters (query/path?) this.apidetails = {}; // Buckets for histograms, with default bucket values this.durationBuckets = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]; this.requestSizeBuckets = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]; this.responseSizeBuckets = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]; // Prometheus metrics in prom-client this.promClientMetrics = {}; } swsAPIStats.prototype.getAPIDefs = function() { return this.apidefs; }; swsAPIStats.prototype.getAPIStats = function() { return this.apistats; }; swsAPIStats.prototype.getAPIOperationStats = function(path,method) { if( (typeof path === 'undefined') || !path || (path==='')) return {}; if( (typeof method === 'undefined') || !method || (method==='')) return {}; var res = {}; res[path] = {}; res[path][method] = {}; // api op defs if( (path in this.apidefs) && (method in this.apidefs[path])) { res[path][method].defs = this.apidefs[path][method]; } // api op stats if( (path in this.apistats) && (method in this.apistats[path])) { res[path][method].stats = this.apistats[path][method]; } // api op details if( (path in this.apidetails) && (method in this.apidetails[path])) { res[path][method].details = this.apidetails[path][method]; } return res; }; swsAPIStats.prototype.initBasePath = function(swaggerSpec,swsOptions) { if(('basePath' in swsOptions) && (swsOptions.basePath!=='')){ this.basePath = swsOptions.basePath; }else{ if( swaggerSpec.openapi && swaggerSpec.openapi.startsWith('3') ){ this.basePath = '/'; }else{ this.basePath = swaggerSpec.basePath ? swaggerSpec.basePath : '/'; if (this.basePath.charAt(0) !== '/') { this.basePath = '/' + this.basePath; } } } if (this.basePath.charAt(this.basePath.length - 1) !== '/') { this.basePath = this.basePath + '/'; } }; // Get full swagger Path swsAPIStats.prototype.getFullPath = function (path) { var fullPath = this.basePath; if (path.charAt(0) === '/') { fullPath += path.substring(1); }else{ fullPath += path; } return fullPath; }; swsAPIStats.prototype.initialize = function(swsOptions) { // TODO remove if(!swsOptions) return; this.options = swsOptions; this.durationBuckets = swsSettings.durationBuckets; this.requestSizeBuckets = swsSettings.requestSizeBuckets; this.responseSizeBuckets = swsSettings.responseSizeBuckets; // Update buckets to reflect passed options swsMetrics.apiMetricsDefs.api_request_duration_milliseconds.buckets = this.durationBuckets; swsMetrics.apiMetricsDefs.api_request_size_bytes.buckets = this.requestSizeBuckets; swsMetrics.apiMetricsDefs.api_response_size_bytes.buckets = this.responseSizeBuckets; swsMetrics.clearPrometheusMetrics(this.promClientMetrics); this.promClientMetrics = swsMetrics.getPrometheusMetrics(swsSettings.metricsPrefix,swsMetrics.apiMetricsDefs); if(!('swaggerSpec' in swsOptions)) return; if(swsOptions.swaggerSpec === null) return; var swaggerSpec = swsOptions.swaggerSpec; this.initBasePath(swaggerSpec,swsOptions); if(!swaggerSpec.paths) return; // Enumerate all paths entries for(var path in swaggerSpec.paths ){ var pathDef = swaggerSpec.paths[path]; // Create full path var fullPath = this.getFullPath(path); // by default, regex is null var keys = []; var re = null; // Convert to express path var fullExpressPath = fullPath; // Create regex if we have path parameters if( fullExpressPath.indexOf('{') !== -1 ) { fullExpressPath = fullExpressPath.replace(/\{/g, ':'); fullExpressPath = fullExpressPath.replace(/\}/g, ''); fullExpressPath = fullExpressPath.replace(/\?(\w+=)/g, '\\?$1'); re = pathToRegexp(fullExpressPath, keys); } // Add to API Match Index, leveraging express style matching this.apiMatchIndex[fullPath] = { re: re, keys: keys, expressPath: fullExpressPath, methods: {}}; var operations = ['get','put','post','delete','options','head','patch']; for(var i=0;i<operations.length;i++){ var op = operations[i]; if(op in pathDef){ var opDef = pathDef[op]; var opMethod = op.toUpperCase(); var apiOpDef = {}; // API Operation definition apiOpDef.swagger = true; // by definition apiOpDef.deprecated = ('deprecated' in opDef) ? opDef.deprecated : false; if( 'description' in opDef ) apiOpDef.description = opDef.description; if( 'operationId' in opDef ) apiOpDef.operationId = opDef.operationId; if( 'summary' in opDef ) apiOpDef.summary = opDef.summary; if( 'tags' in opDef ) apiOpDef.tags = opDef.tags; // Store in match index this.apiMatchIndex[fullPath].methods[opMethod] = apiOpDef; // Store in API Operation definitions. Stored separately so only definition can be retrieved if(!(fullPath in this.apidefs) ) this.apidefs[fullPath] = {}; this.apidefs[fullPath][opMethod] = apiOpDef; // Create Stats for this API Operation; stats stored separately so only stats can be retrieved this.getAPIOpStats(fullPath,opMethod); // Create entry in apidetails this.getApiOpDetails(fullPath,opMethod); // Process parameters for this op this.processParameters(swaggerSpec, pathDef, opDef, fullPath, opMethod); debug('SWS:Initialize API:added %s %s (%s)', op, fullPath, fullExpressPath); } } } }; // Process parameterss for given operation // Take into account parameters defined as common for path (from pathDef) swsAPIStats.prototype.processParameters = function(swaggerSpec, pathDef, opDef, fullPath, opMethod ) { var apidetailsEntry = this.getApiOpDetails(fullPath,opMethod); // Params from path if(('parameters' in pathDef) && (pathDef.parameters instanceof Array)) { var pathParams = pathDef.parameters; for(var j=0;j<pathParams.length;j++){ var param = pathParams[j]; this.processSingleParameter(apidetailsEntry,param); } } // Params from Op, overriding parameters from path if(('parameters' in opDef) && (opDef.parameters instanceof Array)){ var opParams = opDef.parameters; for(var k=0;k<opParams.length;k++){ var param = opParams[k]; this.processSingleParameter(apidetailsEntry,param); } } }; swsAPIStats.prototype.processSingleParameter = function(apidetailsEntry,param) { if( !('parameters' in apidetailsEntry) ) apidetailsEntry.parameters = {}; var params = apidetailsEntry.parameters; var pname = "name" in param ? param.name : null; if( pname === null ) return; if(!(pname in params)) params[pname] = { name: pname }; var paramEntry = params[pname]; // Process all supported parameter properties for( var supportedProp in swsUtil.swsParameterProperties ){ if(supportedProp in param){ paramEntry[supportedProp] = param[supportedProp]; } } // Process all vendor extensions for( var paramProp in param ){ if( paramProp.startsWith('x-') ){ paramEntry[paramProp] = param[paramProp]; } } // Add standard stats paramEntry.hits = 0; paramEntry.misses = 0; }; // Get or create API Operation Details swsAPIStats.prototype.getApiOpDetails = function(path,method) { if(!(path in this.apidetails) ) this.apidetails[path] = {}; if(!(method in this.apidetails[path]) ) this.apidetails[path][method] = { duration: new swsBucketStats(this.durationBuckets), // Request duration histogram req_size: new swsBucketStats(this.requestSizeBuckets), // Request size histogram res_size: new swsBucketStats(this.responseSizeBuckets), // Response size histogram code: {'200':{count:0}} // Counts by response code }; return this.apidetails[path][method]; }; // Get or create API Operation Stats swsAPIStats.prototype.getAPIOpStats = function( path, method ) { if( !(path in this.apistats)) this.apistats[path] = {}; if( !(method in this.apistats[path])) this.apistats[path][method] = new swsReqResStats(this.options.apdexThreshold); return this.apistats[path][method]; }; // Update and stats per tick swsAPIStats.prototype.tick = function (ts,totalElapsedSec) { // Update Rates in apistats for( var path in this.apistats ) { for( var method in this.apistats[path] ) { this.apistats[path][method].updateRates(totalElapsedSec); } } }; // Extract path parameter values based on successful path match results swsAPIStats.prototype.extractPathParams = function (matchResult,keys) { var pathParams = {}; for(var i=0;i<keys.length;i++){ if('name' in keys[i] ) { var vidx = i + 1; // first element in match result is URI if( vidx < matchResult.length ) { pathParams[keys[i].name] = swsUtil.swsStringValue(matchResult[vidx]); } } } return pathParams; }; // Try to match request to API to known API definition swsAPIStats.prototype.matchRequest = function (req) { var url = req.sws.originalUrl; // Handle "/pets" and "/pets/" the same way - #105 if( url.endsWith('/') ) { url = url.slice(0,-1); } req.sws.match = false; // No match by default // Strip query string parameters var qidx = url.indexOf('?'); if(qidx!=-1) { url = url.substring(0,qidx); } var matchEntry = null; var apiPath = null; var apiPathParams = null; var apiInfo = null; // First check if we can find exact match in apiMatchIndex if( url in this.apiMatchIndex ){ matchEntry = this.apiMatchIndex[url]; apiPath = url; debug('SWS:MATCH: %s exact match', url); } else { // if not, search by regex matching for(var swPath in this.apiMatchIndex) { if( this.apiMatchIndex[swPath].re !== null) { var matchResult = this.apiMatchIndex[swPath].re.exec(url); if (matchResult && (matchResult instanceof Array)) { matchEntry = this.apiMatchIndex[swPath]; apiPath = swPath; apiPathParams = this.extractPathParams(matchResult, this.apiMatchIndex[swPath].keys); debug('SWS:MATCH: %s matched to %s', url, swPath); break; // Done } } } } if( matchEntry ) { if (req.method in matchEntry.methods) { apiInfo = matchEntry.methods[req.method]; req.sws.match = true; // Match is found req.sws.api_path = apiPath; req.sws.swagger = true; // When matched, attach only subset of information to request, // so we don't overload reqresinfo with repeated description, etc if ('deprecated' in apiInfo) req.sws.deprecated = apiInfo.deprecated; if ('operationId' in apiInfo) req.sws.operationId = apiInfo.operationId; if ('tags' in apiInfo) req.sws.tags = apiInfo.tags; // Store path parameters from match result if (apiPathParams) req.sws.path_params = apiPathParams; } } }; // Count Api Operation Parameters Statistics // Only count hits and misses // Hit: parameter present // Miss: mandatory parameter is missing // Only supported path and query parameters swsAPIStats.prototype.countParametersStats = function (path, method, req, res) { if(!('swagger' in req.sws) || !req.sws.swagger ) return; // Only counting for swagger-defined API Ops var apiOpDetails = this.getApiOpDetails(path, method); if( !('parameters' in apiOpDetails) ) return; // Only counting if parameters spec is there for( var pname in apiOpDetails.parameters ){ var param = apiOpDetails.parameters[pname]; var isRrequired = 'required' in param ? param.required : false; if( 'in' in param ){ switch(param.in){ case "path": // Path param is always there, or request will not be matched param.hits++; break; case "query": if( ('query' in req.sws) && (pname in req.sws.query) ){ param.hits++; }else if(isRrequired){ param.misses++; } break; } } } }; // Get Api Operation Parameter Values per specification // Only supported path and query parameters swsAPIStats.prototype.getApiOpParameterValues = function (path, method, req, res) { if(!('swagger' in req.sws) || !req.sws.swagger ) return null; // Only for swagger-defined API Ops var apiOpDetails = this.getApiOpDetails(path, method); if( !('parameters' in apiOpDetails) ) return null; // Only if parameters spec is there var paramValues = {}; for( var pname in apiOpDetails.parameters ){ var param = apiOpDetails.parameters[pname]; if( 'in' in param ){ switch(param.in){ case "path": if(('path_params' in req.sws) && (pname in req.sws.path_params)) { paramValues[pname] = swsUtil.swsStringValue(req.sws.path_params[pname]); } break; case "query": if(('query' in req.sws) && (pname in req.sws.query)) { paramValues[pname] = swsUtil.swsStringValue(req.sws.query[pname]); } break; } } } return paramValues; }; // Count request swsAPIStats.prototype.countRequest = function (req, res) { // Count request if it was matched to API Operation if(('match' in req.sws) && req.sws.match ){ var apiOpStats = this.getAPIOpStats(req.sws.api_path,req.method); apiOpStats.countRequest(req.sws.req_clength); this.countParametersStats(req.sws.api_path,req.method, req, res ); } }; // Count finished response swsAPIStats.prototype.countResponse = function (res) { var req = res._swsReq; var codeclass = swsUtil.getStatusCodeClass(res.statusCode); // Only intersted in updating stats here var apiOpStats = this.getAPIOpStats(req.sws.api_path,req.method); // If request was not matched to API operation, // do both count request and count response here, // as only at this time we know path so can map request / response to API entry // This allows supporting API statistics on non-swagger express route APIs, like /path/:param // as express router would attach route.path to request if(!('match' in req.sws) || !req.sws.match) { apiOpStats.countRequest(req.sws.req_clength); } // In all cases, count response here apiOpStats.countResponse(res.statusCode,codeclass,req.sws.duration,req.sws.res_clength); // Count metrics var apiOpDetails = this.getApiOpDetails(req.sws.api_path,req.method); // Metrics by response code if( !('code' in apiOpDetails) ) { apiOpDetails.code = {}; } if(!(res.statusCode in apiOpDetails.code)){ apiOpDetails.code[res.statusCode] = { count:0 }; } apiOpDetails.code[res.statusCode].count++; apiOpDetails.duration.countValue(req.sws.duration); apiOpDetails.req_size.countValue(req.sws.req_clength); apiOpDetails.res_size.countValue(req.sws.res_clength); // update Prometheus metrics this.promClientMetrics.api_request_total.labels(req.method,req.sws.api_path,res.statusCode).inc(); this.promClientMetrics.api_request_duration_milliseconds.labels(req.method,req.sws.api_path,res.statusCode).observe(req.sws.duration); this.promClientMetrics.api_request_size_bytes.labels(req.method,req.sws.api_path,res.statusCode).observe(req.sws.req_clength); this.promClientMetrics.api_response_size_bytes.labels(req.method,req.sws.api_path,res.statusCode).observe(req.sws.res_clength); }; module.exports = swsAPIStats;