diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c3abfe8..53b33960 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Build on: pull_request: - branches: [ master ] + branches: [master] jobs: test: @@ -22,6 +22,11 @@ jobs: steps: - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Fetch all history for all tags and branches + run: git fetch - name: Install Dependencies run: yarn @@ -30,6 +35,4 @@ jobs: run: yarn build - name: Test - run: yarn test - - \ No newline at end of file + run: yarn test:ci diff --git a/README.md b/README.md index fd9ade66..9a04239a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ js extensions for the [open-telemetry](https://opentelemetry.io/) project, from | [opentelemetry-instrumentation-typeorm](./packages/instrumentation-typeorm) | [`TypeORM`](https://typeorm.io/) | [![NPM version](https://img.shields.io/npm/v/opentelemetry-instrumentation-typeorm.svg)](https://www.npmjs.com/package/opentelemetry-instrumentation-typeorm) | | [opentelemetry-instrumentation-sequelize](./packages/instrumentation-sequelize) | [`Sequelize`](https://sequelize.org/) | [![NPM version](https://img.shields.io/npm/v/opentelemetry-instrumentation-sequelize.svg)](https://www.npmjs.com/package/opentelemetry-instrumentation-sequelize) | | [opentelemetry-instrumentation-mongoose](./packages/instrumentation-mongoose) | [`mongoose`](https://mongoosejs.com/) | [![NPM version](https://img.shields.io/npm/v/opentelemetry-instrumentation-mongoose.svg)](https://www.npmjs.com/package/opentelemetry-instrumentation-mongoose) | +| [opentelemetry-instrumentation-elasticsearch](./packages/instrumentation-elasticsearch) | [`elasticsearch`](https://www.npmjs.com/package/@elastic/elasticsearch) | [![NPM version](https://img.shields.io/npm/v/opentelemetry-instrumentation-elasticsearch.svg)](https://www.npmjs.com/package/opentelemetry-instrumentation-elasticsearch) | | [opentelemetry-instrumentation-neo4j](./packages/instrumentation-neo4j) | [`neo4j-driver`](https://github.com/neo4j/neo4j-javascript-driver/) | [![NPM version](https://img.shields.io/npm/v/opentelemetry-instrumentation-neo4j.svg)](https://www.npmjs.com/package/opentelemetry-instrumentation-neo4j) | ## Compatibility with opentelemetry versions diff --git a/package.json b/package.json index 30462ff2..3fc8525c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "test": "lerna run test", - "test:ci": "lerna run test:ci --since master", + "test:ci": "lerna run test:ci --since origin/master", "build": "lerna run build", "postinstall": "lerna bootstrap", "prettier": "prettier --config .prettierrc.yml --write \"**/*.{ts,tsx,js,jsx,json}\"", @@ -31,4 +31,4 @@ "prettier --write" ] } -} \ No newline at end of file +} diff --git a/packages/instrumentation-aws-sdk/package.json b/packages/instrumentation-aws-sdk/package.json index c40d411d..5a12d4a7 100644 --- a/packages/instrumentation-aws-sdk/package.json +++ b/packages/instrumentation-aws-sdk/package.json @@ -24,6 +24,7 @@ "build": "tsc", "prepare": "yarn run build", "test": "mocha", + "test:ci": "yarn test", "watch": "tsc -w", "version:update": "node ../../scripts/version-update.js", "version": "yarn run version:update" diff --git a/packages/instrumentation-elasticsearch/.tav.yml b/packages/instrumentation-elasticsearch/.tav.yml new file mode 100644 index 00000000..2641e4f1 --- /dev/null +++ b/packages/instrumentation-elasticsearch/.tav.yml @@ -0,0 +1,4 @@ +'@elastic/elasticsearch': + versions: "*" + commands: + - yarn test diff --git a/packages/instrumentation-elasticsearch/LICENSE b/packages/instrumentation-elasticsearch/LICENSE new file mode 100644 index 00000000..f49a4e16 --- /dev/null +++ b/packages/instrumentation-elasticsearch/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. \ No newline at end of file diff --git a/packages/instrumentation-elasticsearch/README.md b/packages/instrumentation-elasticsearch/README.md new file mode 100644 index 00000000..d636b5cb --- /dev/null +++ b/packages/instrumentation-elasticsearch/README.md @@ -0,0 +1,45 @@ +# OpenTelemetry Elasticsearch Instrumentation for Node.js +[![NPM version](https://img.shields.io/npm/v/opentelemetry-instrumentation-elasticsearch.svg)](https://www.npmjs.com/package/opentelemetry-instrumentation-elasticsearch) + +This module provides automatic instrumentation for [`@elastic/elasticsearch`](https://github.com/elastic/elasticsearch-js) and follows otel [DB Semantic Conventions](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md). + +## Installation + +``` +npm install opentelemetry-instrumentation-elasticsearch +``` + +## Usage +For further automatic instrumentation instruction see the [@opentelemetry/instrumentation](https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-instrumentation) package. + +```js +const { NodeTracerProvider } = require('@opentelemetry/node'); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); +const { ElasticsearchInstrumentation } = require('opentelemetry-instrumentation-elasticsearch'); + +registerInstrumentations({ + traceProvider, + instrumentations: [ + new ElasticsearchInstrumentation({ + // see under for available configuration + }) + ] +}); +``` + +### Elasticsearch Instrumentation Options + +Elasticsearch instrumentation has few options available to choose from. You can set the following (all optional): + +| Options | Type | Description | +| -------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `suppressInternalInstrumentation` | `boolean` | Elasticsearch operation use http/https under the hood. Setting this to true will hide the underlying request spans (if instrumented). | +| `responseHook` | `ElasticsearchResponseCustomAttributesFunction` | Hook called before response is returned, which allows to add custom attributes to span. | +| `dbStatementSerializer` | `DbStatementSerializer` | Elasticsearch instrumentation will serialize `db.statement` using the specified function. +| `moduleVersionAttributeName` | `string` | If passed, a span attribute will be added to all spans with key of the provided `moduleVersionAttributeName` and value of the patched module version | + +Please make sure `dbStatementSerializer` is error proof, as errors are not handled while executing this function. + +--- + +This extension (and many others) was developed by [Aspecto](https://www.aspecto.io/) with ❤️ diff --git a/packages/instrumentation-elasticsearch/package.json b/packages/instrumentation-elasticsearch/package.json new file mode 100644 index 00000000..6f5833c4 --- /dev/null +++ b/packages/instrumentation-elasticsearch/package.json @@ -0,0 +1,69 @@ +{ + "name": "opentelemetry-instrumentation-elasticsearch", + "version": "0.2.1-dev.1", + "description": "open telemetry instrumentation for the `elasticsearch` module", + "keywords": [ + "elasticsearch", + "@elastic/elasticsearch", + "opentelemetry" + ], + "author": { + "name": "Aspecto", + "email": "support@aspecto.io", + "url": "https://aspecto.io" + }, + "homepage": "https://github.com/aspecto-io/opentelemetry-ext-js", + "license": "Apache-2.0", + "main": "dist/src/index.js", + "files": [ + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "LICENSE", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/aspecto-io/opentelemetry-ext-js.git" + }, + "scripts": { + "build": "tsc", + "prepare": "yarn run build", + "watch": "tsc -w", + "version:update": "node ../../scripts/version-update.js", + "version": "yarn run version:update", + "test": "mocha", + "test-all-versions": "tav", + "test:ci": "yarn test-all-versions" + }, + "bugs": { + "url": "https://github.com/aspecto-io/opentelemetry-ext-js/issues" + }, + "dependencies": { + "@opentelemetry/api": "^0.17.0", + "@opentelemetry/instrumentation": "^0.17.0", + "@opentelemetry/semantic-conventions": "^0.17.0" + }, + "devDependencies": { + "@elastic/elasticsearch": "^7.8.0", + "@opentelemetry/node": "^0.17.0", + "@opentelemetry/tracing": "^0.17.0", + "@types/chai": "^4.2.15", + "@types/mocha": "^8.2.1", + "chai": "^4.3.0", + "expect": "^26.6.2", + "mocha": "^8.3.0", + "nock": "^13.0.9", + "sinon": "^9.2.4", + "test-all-versions": "^5.0.1", + "ts-node": "^9.1.1", + "typescript": "^4.0.3" + }, + "mocha": { + "extension": [ + "ts" + ], + "spec": "test/**/*.spec.ts", + "require": "ts-node/register" + } +} diff --git a/packages/instrumentation-elasticsearch/src/elasticsearch.ts b/packages/instrumentation-elasticsearch/src/elasticsearch.ts new file mode 100644 index 00000000..b06c76fe --- /dev/null +++ b/packages/instrumentation-elasticsearch/src/elasticsearch.ts @@ -0,0 +1,177 @@ +import { diag, context, suppressInstrumentation, setSpan, Span } from '@opentelemetry/api'; +import type elasticsearch from '@elastic/elasticsearch'; +import { ElasticsearchInstrumentationConfig } from './types'; +import { + InstrumentationBase, + InstrumentationModuleDefinition, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import { VERSION } from './version'; +import { AttributeNames } from './enums'; +import { DatabaseAttribute } from '@opentelemetry/semantic-conventions'; +import { + startSpan, + onError, + onResponse, + defaultDbStatementSerializer, + normalizeArguments, + getIndexName, +} from './utils'; +import { ELASTICSEARCH_API_FILES } from './helpers'; + +export class ElasticsearchInstrumentation extends InstrumentationBase { + static readonly component = '@elastic/elasticsearch'; + + protected _config: ElasticsearchInstrumentationConfig; + private _isEnabled = false; + private moduleVersion: string; + + constructor(config: ElasticsearchInstrumentationConfig = {}) { + super('opentelemetry-instrumentation-elasticsearch', VERSION, Object.assign({}, config)); + } + + setConfig(config: ElasticsearchInstrumentationConfig = {}) { + this._config = Object.assign({}, config); + } + + protected init(): InstrumentationModuleDefinition { + const apiModuleFiles = ELASTICSEARCH_API_FILES.map( + ({ path, operationClassName }) => + new InstrumentationNodeModuleFile( + `@elastic/elasticsearch/api/${path}`, + ['*'], + this.patch.bind(this, operationClassName), + this.unpatch.bind(this) + ) + ); + + const module = new InstrumentationNodeModuleDefinition( + ElasticsearchInstrumentation.component, + ['*'], + undefined, + undefined, + apiModuleFiles + ); + + return module; + } + + private patchObject(operationClassName: string, object) { + Object.keys(object).forEach((functionName) => { + if (typeof object[functionName] === 'object') { + this.patchObject(functionName, object[functionName]); + } else { + this._wrap(object, functionName, this.wrappedApiRequest.bind(this, operationClassName, functionName)); + } + }); + } + + protected patch(operationClassName: string, moduleExports, moduleVersion: string) { + diag.debug(`elasticsearch instrumentation: patch elasticsearch ${operationClassName}.`); + this.moduleVersion = moduleVersion; + this._isEnabled = true; + + const modulePrototypeKeys = Object.keys(moduleExports.prototype); + if (modulePrototypeKeys.length > 0) { + modulePrototypeKeys.forEach((functionName) => { + this._wrap( + moduleExports.prototype, + functionName, + this.wrappedApiRequest.bind(this, operationClassName, functionName) + ); + }); + return moduleExports; + } + + // For versions <= 7.9.0 + const self = this; + return function (opts) { + const module = moduleExports(opts); + self.patchObject(operationClassName, module); + return module; + }; + } + + protected unpatch(moduleExports) { + diag.debug(`elasticsearch instrumentation: unpatch elasticsearch.`); + this._isEnabled = false; + + const modulePrototypeKeys = Object.keys(moduleExports.prototype); + if (modulePrototypeKeys.length > 0) { + modulePrototypeKeys.forEach((functionName) => { + this._unwrap(moduleExports.prototype, functionName); + }); + } else { + // Unable to unwrap function for versions <= 7.9.0. Using _isEnabled flag instead. + } + } + + private wrappedApiRequest(apiClassName: string, functionName: string, originalFunction: Function) { + const self = this; + return function (...args) { + if (!self._isEnabled) { + return originalFunction.apply(this, args); + } + + const [params, options, originalCallback] = normalizeArguments(args[0], args[1], args[2]); + const operation = `${apiClassName}.${functionName}`; + const span = startSpan({ + tracer: self.tracer, + attributes: { + [DatabaseAttribute.DB_OPERATION]: operation, + [AttributeNames.ELASTICSEARCH_INDICES]: getIndexName(params), + [DatabaseAttribute.DB_STATEMENT]: ( + self._config.dbStatementSerializer || defaultDbStatementSerializer + )(operation, params, options), + }, + }); + self._addModuleVersionIfNeeded(span); + + if (originalCallback) { + const wrappedCallback = function (err, result) { + if (err) { + onError(span, err); + } else { + onResponse(span, result, self._config.responseHook); + } + + return originalCallback.call(this, err, result); + }; + + return self._callOriginalFunction(span, () => + originalFunction.call(this, params, options, wrappedCallback) + ); + } else { + const promise = self._callOriginalFunction(span, () => originalFunction.apply(this, args)); + promise.then( + (result) => { + onResponse(span, result, self._config.responseHook); + return result; + }, + (err) => { + onError(span, err); + return err; + } + ); + + return promise; + } + }; + } + + private _callOriginalFunction(span: Span, originalFunction: (...args: any[]) => T): T { + if (this._config?.suppressInternalInstrumentation) { + return context.with(suppressInstrumentation(context.active()), originalFunction); + } else { + const activeContextWithSpan = setSpan(context.active(), span); + return context.with(activeContextWithSpan, originalFunction); + } + } + + private _addModuleVersionIfNeeded(span: Span) { + if (this._config.moduleVersionAttributeName) { + span.setAttribute(this._config.moduleVersionAttributeName, this.moduleVersion); + } + } +} diff --git a/packages/instrumentation-elasticsearch/src/enums.ts b/packages/instrumentation-elasticsearch/src/enums.ts new file mode 100644 index 00000000..2634481b --- /dev/null +++ b/packages/instrumentation-elasticsearch/src/enums.ts @@ -0,0 +1,3 @@ +export enum AttributeNames { + ELASTICSEARCH_INDICES = 'elasticsearch.request.indices', +} diff --git a/packages/instrumentation-elasticsearch/src/helpers.ts b/packages/instrumentation-elasticsearch/src/helpers.ts new file mode 100644 index 00000000..b1de503b --- /dev/null +++ b/packages/instrumentation-elasticsearch/src/helpers.ts @@ -0,0 +1,33 @@ +export const ELASTICSEARCH_API_FILES = [ + { path: 'index.js', operationClassName: 'client' }, + { path: 'api/async_search.js', operationClassName: 'asyncSearch' }, + { path: 'api/autoscaling.js', operationClassName: 'autoscaling' }, + { path: 'api/cat.js', operationClassName: 'cat' }, + { path: 'api/ccr.js', operationClassName: 'ccr' }, + { path: 'api/cluster.js', operationClassName: 'cluster' }, + { path: 'api/dangling_indices.js', operationClassName: 'dangling_indices' }, + { path: 'api/enrich.js', operationClassName: 'enrich' }, + { path: 'api/eql.js', operationClassName: 'eql' }, + { path: 'api/graph.js', operationClassName: 'graph' }, + { path: 'api/ilm.js', operationClassName: 'ilm' }, + { path: 'api/indices.js', operationClassName: 'indices' }, + { path: 'api/ingest.js', operationClassName: 'ingest' }, + { path: 'api/license.js', operationClassName: 'license' }, + { path: 'api/logstash.js', operationClassName: 'logstash' }, + { path: 'api/migration.js', operationClassName: 'migration' }, + { path: 'api/ml.js', operationClassName: 'ml' }, + { path: 'api/monitoring.js', operationClassName: 'monitoring' }, + { path: 'api/nodes.js', operationClassName: 'nodes' }, + { path: 'api/rollup.js', operationClassName: 'rollup' }, + { path: 'api/searchable_snapshots.js', operationClassName: 'searchable_snapshots' }, + { path: 'api/security.js', operationClassName: 'security' }, + { path: 'api/slm.js', operationClassName: 'slm' }, + { path: 'api/snapshot.js', operationClassName: 'snapshot' }, + { path: 'api/sql.js', operationClassName: 'sql' }, + { path: 'api/ssl.js', operationClassName: 'ssl' }, + { path: 'api/tasks.js', operationClassName: 'tasks' }, + { path: 'api/text_structure.js', operationClassName: 'text_structure' }, + { path: 'api/transform.js', operationClassName: 'transform' }, + { path: 'api/watcher.js', operationClassName: 'watcher' }, + { path: 'api/xpack.js', operationClassName: 'xpack' }, +]; diff --git a/packages/instrumentation-elasticsearch/src/index.ts b/packages/instrumentation-elasticsearch/src/index.ts new file mode 100644 index 00000000..0445829f --- /dev/null +++ b/packages/instrumentation-elasticsearch/src/index.ts @@ -0,0 +1,2 @@ +export * from './elasticsearch'; +export * from './types'; diff --git a/packages/instrumentation-elasticsearch/src/types.ts b/packages/instrumentation-elasticsearch/src/types.ts new file mode 100644 index 00000000..74ba71e5 --- /dev/null +++ b/packages/instrumentation-elasticsearch/src/types.ts @@ -0,0 +1,29 @@ +import { Span } from '@opentelemetry/api'; +import { InstrumentationConfig } from '@opentelemetry/instrumentation'; + +export type DbStatementSerializer = (operation?: string, params?: object, options?: object) => string; + +export type ElasticsearchResponseCustomAttributesFunction = (span: Span, response: any) => void; + +export interface ElasticsearchInstrumentationConfig extends InstrumentationConfig { + /** + * Elasticsearch operation use http/https under the hood. + * If Elasticsearch instrumentation is enabled, an http/https operation will also create. + * Setting the `suppressInternalInstrumentation` config value to `true` will + * cause the instrumentation to suppress instrumentation of underlying operations, + * effectively causing http/https spans to be non-recordable. + */ + suppressInternalInstrumentation?: boolean; + + /** Custom serializer function for the db.statement tag */ + dbStatementSerializer?: DbStatementSerializer; + + /** hook for adding custom attributes using the response payload */ + responseHook?: ElasticsearchResponseCustomAttributesFunction; + + /** + * If passed, a span attribute will be added to all spans with key of the provided "moduleVersionAttributeName" + * and value of the module version. + */ + moduleVersionAttributeName?: string; +} diff --git a/packages/instrumentation-elasticsearch/src/utils.ts b/packages/instrumentation-elasticsearch/src/utils.ts new file mode 100644 index 00000000..5c878a30 --- /dev/null +++ b/packages/instrumentation-elasticsearch/src/utils.ts @@ -0,0 +1,108 @@ +import { Tracer, SpanAttributes, SpanStatusCode, diag, Span, SpanKind } from '@opentelemetry/api'; +import { DbStatementSerializer, ElasticsearchResponseCustomAttributesFunction } from './types'; +import { safeExecuteInTheMiddle } from '@opentelemetry/instrumentation'; +import { DatabaseAttribute, GeneralAttribute } from '@opentelemetry/semantic-conventions'; +import { ApiResponse } from '@elastic/elasticsearch/lib/Transport'; + +interface StartSpanPayload { + tracer: Tracer; + attributes: SpanAttributes; +} + +export function getIndexName(params) { + if (!params?.index) { + return undefined; + } + + if (typeof params.index === 'string') { + return params.index; + } + + if (Array.isArray(params.index)) { + return params.index.join(','); + } +} + +export function startSpan({ tracer, attributes }: StartSpanPayload): Span { + return tracer.startSpan('elasticsearch.request', { + kind: SpanKind.CLIENT, + attributes: { + [DatabaseAttribute.DB_SYSTEM]: 'elasticsearch', + ...attributes, + }, + }); +} + +export function normalizeArguments(params, options, callback) { + // Copied normalizeArguments function from @elastic/elasticsearch + if (typeof options === 'function') { + callback = options; + options = {}; + } + if (typeof params === 'function' || params == null) { + callback = params; + params = {}; + options = {}; + } + return [params, options, callback]; +} + +export function getPort(port: string, protocol: string): string { + if (port) return port; + + if (protocol === 'https:') return '443'; + if (protocol === 'http:') return '80'; + + return ''; +} + +export function getNetAttributes(url: string): SpanAttributes { + const { port, protocol, hostname } = new URL(url); + + return { + [GeneralAttribute.NET_TRANSPORT]: 'IP.TCP', + [GeneralAttribute.NET_PEER_NAME]: hostname, + [GeneralAttribute.NET_PEER_PORT]: getPort(port, protocol), + }; +} + +export function onResponse( + span: Span, + result: ApiResponse, + responseHook?: ElasticsearchResponseCustomAttributesFunction +) { + span.setAttributes({ + ...getNetAttributes(result.meta.connection.url.toString()), + }); + + span.setStatus({ + code: SpanStatusCode.OK, + }); + + if (responseHook) { + safeExecuteInTheMiddle( + () => responseHook(span, result), + (e) => { + if (e) { + diag.error('elasticsearch instrumentation: responseHook error', e); + } + }, + true + ); + } + + span.end(); +} + +export function onError(span: Span, err) { + span.recordException(err); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + + span.end(); +} + +export const defaultDbStatementSerializer: DbStatementSerializer = (operation, params, options) => + JSON.stringify({ params, options }); diff --git a/packages/instrumentation-elasticsearch/src/version.ts b/packages/instrumentation-elasticsearch/src/version.ts new file mode 100644 index 00000000..a90eb6f4 --- /dev/null +++ b/packages/instrumentation-elasticsearch/src/version.ts @@ -0,0 +1,2 @@ +// this is autogenerated file, see scripts/version-update.js +export const VERSION = '0.2.1-dev.1'; diff --git a/packages/instrumentation-elasticsearch/test/elastic.spec.ts b/packages/instrumentation-elasticsearch/test/elastic.spec.ts new file mode 100644 index 00000000..e1f95610 --- /dev/null +++ b/packages/instrumentation-elasticsearch/test/elastic.spec.ts @@ -0,0 +1,100 @@ +import 'mocha'; +import nock from 'nock'; +import { expect } from 'chai'; +import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/tracing'; +import { NodeTracerProvider } from '@opentelemetry/node'; +import { ElasticsearchInstrumentation } from '../src/elasticsearch'; + +const instrumentation = new ElasticsearchInstrumentation(); + +import { Client } from '@elastic/elasticsearch'; +const esMockUrl = 'http://localhost:9200'; +const esNock = nock(esMockUrl); +const client = new Client({ node: esMockUrl }); + +describe('elasticsearch instrumentation', () => { + const provider = new NodeTracerProvider(); + const memoryExporter = new InMemorySpanExporter(); + const spanProcessor = new SimpleSpanProcessor(memoryExporter); + provider.addSpanProcessor(spanProcessor); + instrumentation.setTracerProvider(provider); + + before(() => { + instrumentation.enable(); + }); + + after(() => { + instrumentation.disable(); + }); + + beforeEach(() => { + memoryExporter.reset(); + }); + + it('should create valid span', async () => { + esNock.get('/the-simpsons/_search').reply(200, {}); + esNock.post('/the-simpsons/_doc').reply(200, {}); + + await client.index({ + index: 'the-simpsons', + type: '_doc', + body: { + character: 'Homer Simpson', + quote: 'Doh!', + }, + }); + + await client.search({ + index: 'the-simpsons', + }); + + const spans = memoryExporter.getFinishedSpans(); + expect(spans?.length).to.equal(2); + expect(spans[0].attributes).to.deep.equal({ + 'db.system': 'elasticsearch', + 'elasticsearch.request.indices': 'the-simpsons', + 'db.operation': 'client.index', + 'db.statement': + '{"params":{"index":"the-simpsons","type":"_doc","body":{"character":"Homer Simpson","quote":"Doh!"}}}', + 'net.transport': 'IP.TCP', + 'net.peer.name': 'localhost', + 'net.peer.port': '9200', + }); + expect(spans[1].attributes).to.deep.equal({ + 'db.system': 'elasticsearch', + 'elasticsearch.request.indices': 'the-simpsons', + 'db.operation': 'client.search', + 'db.statement': '{"params":{"index":"the-simpsons"}}', + 'net.transport': 'IP.TCP', + 'net.peer.name': 'localhost', + 'net.peer.port': '9200', + }); + }); + + it('should create another valid span', async () => { + esNock.get('/_cluster/settings').reply(200, {}); + + await client.cluster.getSettings(); + const spans = memoryExporter.getFinishedSpans(); + + expect(spans?.length).to.equal(1); + expect(spans[0].attributes).to.deep.equal({ + 'db.system': 'elasticsearch', + 'db.operation': 'cluster.getSettings', + 'db.statement': '{"params":{},"options":{}}', + 'net.transport': 'IP.TCP', + 'net.peer.name': 'localhost', + 'net.peer.port': '9200', + }); + }); + + it('should not create spans when instrument disabled', async () => { + esNock.get('/_cluster/settings').reply(200, {}); + + instrumentation.disable(); + await client.cluster.getSettings(); + instrumentation.enable(); + const spans = memoryExporter.getFinishedSpans(); + expect(spans?.length).to.equal(0); + }); +}); diff --git a/packages/instrumentation-elasticsearch/test/utils.spec.ts b/packages/instrumentation-elasticsearch/test/utils.spec.ts new file mode 100644 index 00000000..5c8472dd --- /dev/null +++ b/packages/instrumentation-elasticsearch/test/utils.spec.ts @@ -0,0 +1,196 @@ +import 'mocha'; +import { stub, assert, spy } from 'sinon'; +import { expect } from 'chai'; +import * as Utils from '../src/utils'; +import { SpanKind, SpanStatusCode } from '@opentelemetry/api'; +import { DatabaseAttribute, GeneralAttribute } from '@opentelemetry/semantic-conventions'; + +describe('elasticsearch utils', () => { + const spanMock = { + recordException: (err) => {}, + setStatus: (obj) => {}, + end: () => {}, + setAttributes: (obj) => {}, + }; + + context('defaultDbStatementSerializer', () => { + it('should serialize', () => { + const result = Utils.defaultDbStatementSerializer('operationName', { index: 'test' }, {}); + expect(result).to.equal('{"params":{"index":"test"},"options":{}}'); + }); + }); + + context('onError', () => { + it('should record error', () => { + const recordExceptionStub = stub(spanMock, 'recordException'); + const setStatusStub = stub(spanMock, 'setStatus'); + const endStub = stub(spanMock, 'end'); + + const error = new Error('test error'); + + // @ts-ignore + Utils.onError(spanMock, error); + + assert.calledOnce(recordExceptionStub); + assert.calledWith(recordExceptionStub, error); + + assert.calledOnce(setStatusStub); + assert.calledWith(setStatusStub, { code: SpanStatusCode.ERROR, message: error.message }); + + assert.calledOnce(endStub); + + recordExceptionStub.restore(); + setStatusStub.restore(); + endStub.restore(); + }); + }); + + context('onResponse', () => { + it('should record response without responseHook', () => { + const setAttributesStub = stub(spanMock, 'setAttributes'); + const setStatusStub = stub(spanMock, 'setStatus'); + const endStub = stub(spanMock, 'end'); + + // @ts-ignore + Utils.onResponse(spanMock, { meta: { connection: { url: 'http://localhost' } } }); + + assert.calledOnce(setAttributesStub); + assert.calledOnce(setStatusStub); + assert.calledOnce(endStub); + assert.calledWith(setStatusStub, { code: SpanStatusCode.OK }); + + setAttributesStub.restore(); + setStatusStub.restore(); + endStub.restore(); + }); + + it('should record response with responseHook', () => { + const setAttributesStub = stub(spanMock, 'setAttributes'); + const setStatusStub = stub(spanMock, 'setStatus'); + const endStub = stub(spanMock, 'end'); + + const responseHook = spy(); + + // @ts-ignore + Utils.onResponse(spanMock, { meta: { connection: { url: 'http://localhost' } } }, responseHook); + + assert.calledOnce(setAttributesStub); + assert.calledOnce(setStatusStub); + assert.calledOnce(endStub); + assert.calledWith(setStatusStub, { code: SpanStatusCode.OK }); + + expect(responseHook.called).to.be.true; + + setAttributesStub.restore(); + setStatusStub.restore(); + endStub.restore(); + }); + }); + + context('getNetAttributes', () => { + const url = 'http://localhost:9200'; + const attributes = Utils.getNetAttributes(url); + + it('should get hostname from url', () => { + expect(attributes[GeneralAttribute.NET_PEER_NAME]).to.equal('localhost'); + }); + + it('should get hostname from url', () => { + expect(attributes[GeneralAttribute.NET_PEER_PORT]).to.equal('9200'); + }); + + it('should set net.transport', () => { + expect(attributes[GeneralAttribute.NET_TRANSPORT]).to.equal('IP.TCP'); + }); + }); + + context('getPort', () => { + it('should get port', () => { + const result = Utils.getPort('3030', 'http:'); + expect(result).to.equal('3030'); + }); + + it('should get port from http protocol', () => { + const result = Utils.getPort('', 'http:'); + expect(result).to.equal('80'); + }); + + it('should get port from https protocol', () => { + const result = Utils.getPort('', 'https:'); + expect(result).to.equal('443'); + }); + }); + + context('normalizeArguments', () => { + it('should normalize with callback only', () => { + const callbackFunction = () => {}; + // @ts-ignore + const [params, options, callback] = Utils.normalizeArguments(callbackFunction); + + expect(params).to.be.empty; + expect(options).to.be.empty; + expect(callback).to.be.equal(callbackFunction); + }); + + it('should normalize with params only', () => { + // @ts-ignore + const [params, options, callback] = Utils.normalizeArguments({ index: 'test' }); + + expect(params).to.deep.equal({ index: 'test' }); + expect(options).to.be.undefined; + expect(callback).to.be.undefined; + }); + }); + + context('getIndexName', () => { + it('should accept index string', () => { + const index = Utils.getIndexName({ index: 'test' }); + expect(index).to.equal('test'); + }); + + it('should accept index array', () => { + const indexes = Utils.getIndexName({ index: ['index1', 'index2'] }); + + expect(indexes).to.equal('index1,index2'); + }); + + it('should accept no index', () => { + const undefinedParams = Utils.getIndexName(undefined); + const emptyObject = Utils.getIndexName({}); + + expect(undefinedParams).to.be.undefined; + expect(emptyObject).to.be.undefined; + }); + + it('should ignore unexpected index', () => { + const functionIndex = Utils.getIndexName({ index: () => {} }); + const objectIndex = Utils.getIndexName({ index: {} }); + + expect(functionIndex).to.be.undefined; + expect(objectIndex).to.be.undefined; + }); + }); + + context('startSpan', () => { + const tracerMock = { + startSpan: (name, options?, context?): any => {}, + }; + it('should start span with client kink', () => { + const startSpanStub = stub(tracerMock, 'startSpan'); + + Utils.startSpan({ + tracer: tracerMock, + attributes: { testAttribute: 'testValue' }, + }); + + assert.calledOnce(startSpanStub); + + const [operation, options] = startSpanStub.getCall(0).args; + + expect(operation).to.equal('elasticsearch.request'); + expect(options.kind).to.equal(SpanKind.CLIENT); + expect(options.attributes[DatabaseAttribute.DB_SYSTEM]).to.equal('elasticsearch'); + expect(options.attributes.testAttribute).to.equal('testValue'); + }); + }); +}); diff --git a/packages/instrumentation-elasticsearch/tsconfig.json b/packages/instrumentation-elasticsearch/tsconfig.json new file mode 100644 index 00000000..8d8c772a --- /dev/null +++ b/packages/instrumentation-elasticsearch/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "./dist" + } +} diff --git a/packages/instrumentation-kafkajs/package.json b/packages/instrumentation-kafkajs/package.json index 8c273bb6..9501de76 100644 --- a/packages/instrumentation-kafkajs/package.json +++ b/packages/instrumentation-kafkajs/package.json @@ -24,6 +24,7 @@ "build": "tsc", "prepare": "yarn run build", "test": "mocha", + "test:ci": "yarn test", "watch": "tsc -w", "version:update": "node ../../scripts/version-update.js", "version": "yarn run version:update" diff --git a/packages/instrumentation-mongoose/README.md b/packages/instrumentation-mongoose/README.md index 6b1d5599..a01d937d 100644 --- a/packages/instrumentation-mongoose/README.md +++ b/packages/instrumentation-mongoose/README.md @@ -16,7 +16,7 @@ For further automatic instrumentation instruction see the [@opentelemetry/instru ```js const { NodeTracerProvider } = require('@opentelemetry/node'); const { registerInstrumentations } = require('@opentelemetry/instrumentation'); -const { SequelizeInstrumentation } = require('opentelemetry-instrumentation-mongoose'); +const { MongooseInstrumentation } = require('opentelemetry-instrumentation-mongoose'); registerInstrumentations({ traceProvider, diff --git a/packages/instrumentation-mongoose/package.json b/packages/instrumentation-mongoose/package.json index b723c151..3a639ff4 100644 --- a/packages/instrumentation-mongoose/package.json +++ b/packages/instrumentation-mongoose/package.json @@ -30,6 +30,7 @@ "build": "tsc", "prepare": "yarn run build", "test": "mocha", + "test:ci": "yarn test", "watch": "tsc -w", "version:update": "node ../../scripts/version-update.js", "version": "yarn run version:update" diff --git a/packages/instrumentation-sequelize/package.json b/packages/instrumentation-sequelize/package.json index bc995636..233b9a79 100644 --- a/packages/instrumentation-sequelize/package.json +++ b/packages/instrumentation-sequelize/package.json @@ -29,6 +29,7 @@ "build": "tsc", "prepare": "yarn run build", "test": "mocha", + "test:ci": "yarn test", "watch": "tsc -w", "version:update": "node ../../scripts/version-update.js", "version": "yarn run version:update" diff --git a/packages/instrumentation-typeorm/package.json b/packages/instrumentation-typeorm/package.json index f724da67..a67f2674 100644 --- a/packages/instrumentation-typeorm/package.json +++ b/packages/instrumentation-typeorm/package.json @@ -24,6 +24,7 @@ "build": "tsc", "prepare": "yarn run build", "test": "mocha", + "test:ci": "yarn test", "watch": "tsc -w", "version:update": "node ../../scripts/version-update.js", "version": "yarn run version:update"