diff --git a/.github/component_owners.yml b/.github/component_owners.yml index de81de5b1d..60a8f5c129 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -94,6 +94,9 @@ components: - trentm packages/instrumentation-cassandra-driver: - seemk + packages/instrumentation-console: + - jacksonweber + - hectorhdzg packages/instrumentation-connect: [] # Unmaintained packages/instrumentation-dns: [] diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4929a8997e..67cf87abce 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -36,6 +36,7 @@ "packages/instrumentation-aws-sdk": "0.69.0", "packages/instrumentation-bunyan": "0.59.0", "packages/instrumentation-cassandra-driver": "0.59.0", + "packages/instrumentation-console": "0.1.0", "packages/instrumentation-connect": "0.57.0", "packages/instrumentation-dns": "0.57.0", "packages/instrumentation-express": "0.62.0", diff --git a/package-lock.json b/package-lock.json index db3da71029..7cb398aa9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9167,6 +9167,10 @@ "resolved": "packages/instrumentation-connect", "link": true }, + "node_modules/@opentelemetry/instrumentation-console": { + "resolved": "packages/instrumentation-console", + "link": true + }, "node_modules/@opentelemetry/instrumentation-cucumber": { "resolved": "packages/instrumentation-cucumber", "link": true @@ -36677,6 +36681,117 @@ "@opentelemetry/api": "^1.3.0" } }, + "packages/instrumentation-console": { + "name": "@opentelemetry/instrumentation-console", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "^0.213.0", + "@opentelemetry/instrumentation": "^0.213.0" + }, + "devDependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-logs": "^0.213.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.1" + } + }, + "packages/instrumentation-console/node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "packages/instrumentation-console/node_modules/@opentelemetry/api-logs": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.213.0.tgz", + "integrity": "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "packages/instrumentation-console/node_modules/@opentelemetry/core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", + "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "packages/instrumentation-console/node_modules/@opentelemetry/instrumentation": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.213.0.tgz", + "integrity": "sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "packages/instrumentation-console/node_modules/@opentelemetry/resources": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", + "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "packages/instrumentation-console/node_modules/@opentelemetry/sdk-logs": { + "version": "0.213.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz", + "integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.213.0", + "@opentelemetry/core": "2.6.0", + "@opentelemetry/resources": "2.6.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, "packages/instrumentation-cucumber": { "name": "@opentelemetry/instrumentation-cucumber", "version": "0.30.0", diff --git a/packages/instrumentation-console/CHANGELOG.md b/packages/instrumentation-console/CHANGELOG.md new file mode 100644 index 0000000000..8d630ec313 --- /dev/null +++ b/packages/instrumentation-console/CHANGELOG.md @@ -0,0 +1,2 @@ + +# Changelog diff --git a/packages/instrumentation-console/LICENSE b/packages/instrumentation-console/LICENSE new file mode 100644 index 0000000000..5a13f255df --- /dev/null +++ b/packages/instrumentation-console/LICENSE @@ -0,0 +1,199 @@ + 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 the 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 the 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 any 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. Please also get an approval + from the project maintainers before using the Apache License. + + Copyright The OpenTelemetry Authors + + 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. diff --git a/packages/instrumentation-console/README.md b/packages/instrumentation-console/README.md new file mode 100644 index 0000000000..6ac580eee7 --- /dev/null +++ b/packages/instrumentation-console/README.md @@ -0,0 +1,92 @@ +# OpenTelemetry Instrumentation for Node.js Console + +[![NPM Published Version][npm-img]][npm-url] +[![Apache License][license-image]][license-image] + +This module provides automatic instrumentation for the Node.js [`console`](https://nodejs.org/api/console.html) module, generating OpenTelemetry LogRecords for console method calls (`console.log`, `console.error`, `console.warn`, etc.). + +## Installation + +```bash +npm install @opentelemetry/instrumentation-console +``` + +## Supported Versions + +- Node.js `^18.19.0 || >=20.6.0` + +## Usage + +```javascript +const { NodeSDK } = require('@opentelemetry/sdk-node'); +const { ConsoleInstrumentation } = require('@opentelemetry/instrumentation-console'); + +const sdk = new NodeSDK({ + instrumentations: [new ConsoleInstrumentation()], +}); +sdk.start(); + +// Now console calls will generate LogRecords +console.log('Hello, world!'); // severity: INFO +console.warn('Watch out!'); // severity: WARN +console.error('Something bad'); // severity: ERROR +``` + +## Console Methods Instrumented + +| Console Method | Severity Number | Severity Text | +| ---------------- | --------------- | ------------- | +| `console.trace` | TRACE | TRACE | +| `console.debug` | DEBUG | DEBUG | +| `console.log` | INFO | INFO | +| `console.info` | INFO | INFO | +| `console.warn` | WARN | WARN | +| `console.error` | ERROR | ERROR | +| `console.dir` | INFO | INFO | + +## Configuration + +| Option | Type | Default | Description | +| ----------------------- | --------------------- | ------- | ------------------------------------------------------------------ | +| `disableLogSending` | `boolean` | `false` | Disable sending log records to the OTel Logs SDK | +| `disableLogCorrelation` | `boolean` | `false` | Disable injecting trace context fields into log record attributes | +| `logSeverity` | `SeverityNumber` | — | Minimum severity level; only logs at or above this level are sent | +| `logHook` | `LogHookFunction` | — | Hook to inject additional fields into log records | + +### logHook Example + +```javascript +new ConsoleInstrumentation({ + logHook: (span, record) => { + record['custom.field'] = 'value'; + }, +}); +``` + +## Trace Context Correlation + +When a console method is called within an active span context, the resulting LogRecord will automatically include `trace_id`, `span_id`, and `trace_flags` attributes for log correlation. + +## Important: Infinite Loop Prevention + +This instrumentation requires `@opentelemetry/api >= 1.9.1`. Starting from that version, the `DiagConsoleLogger` saves references to the original `console` methods at module load time, so internal OTel diagnostic logging bypasses the instrumentation and avoids infinite loops. + +Additionally, this instrumentation includes a re-entrancy guard to prevent loops from exporters that write to the console (e.g., `ConsoleLogRecordExporter`). + +> **Note:** Avoid using `ConsoleLogRecordExporter` or `ConsoleSpanExporter` together with this instrumentation in production. Use network-based exporters (e.g., OTLP) instead. + +## Useful links + +- For more information on OpenTelemetry, visit: +- For more about OpenTelemetry JavaScript: +- For help or feedback on this project, join us in [GitHub Discussions][discussions-url] + +## License + +Apache 2.0 - See [LICENSE][license-url] for more information. + +[discussions-url]: https://github.com/open-telemetry/opentelemetry-js/discussions +[license-url]: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/LICENSE +[license-image]: https://img.shields.io/badge/license-Apache_2.0-green.svg?style=flat +[npm-url]: https://www.npmjs.com/package/@opentelemetry/instrumentation-console +[npm-img]: https://badge.fury.io/js/%40opentelemetry%2Finstrumentation-console.svg diff --git a/packages/instrumentation-console/package.json b/packages/instrumentation-console/package.json new file mode 100644 index 0000000000..cef56f2ebb --- /dev/null +++ b/packages/instrumentation-console/package.json @@ -0,0 +1,57 @@ +{ + "name": "@opentelemetry/instrumentation-console", + "version": "0.1.0", + "description": "OpenTelemetry instrumentation for Node.js `console`", + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/open-telemetry/opentelemetry-js-contrib.git", + "directory": "packages/instrumentation-console" + }, + "scripts": { + "clean": "rimraf build/*", + "compile": "tsc -p .", + "compile:with-dependencies": "nx run-many -t compile -p @opentelemetry/instrumentation-console", + "lint:readme": "node ../../scripts/lint-readme.js", + "prepublishOnly": "npm run compile", + "tdd": "npm run test -- --watch-extensions ts --watch", + "test": "nyc --no-clean mocha 'test/**/*.test.ts'", + "version:update": "node ../../scripts/version-update.js" + }, + "keywords": [ + "console", + "instrumentation", + "logging", + "nodejs", + "opentelemetry", + "tracing" + ], + "author": "OpenTelemetry Authors", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.js.map", + "build/src/**/*.d.ts" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.1" + }, + "devDependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-logs": "^0.213.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0" + }, + "dependencies": { + "@opentelemetry/api-logs": "^0.213.0", + "@opentelemetry/instrumentation": "^0.213.0" + }, + "homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/packages/instrumentation-console#readme" +} diff --git a/packages/instrumentation-console/src/index.ts b/packages/instrumentation-console/src/index.ts new file mode 100644 index 0000000000..eee454a7bc --- /dev/null +++ b/packages/instrumentation-console/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright The OpenTelemetry Authors + * + * 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 + * + * https://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. + */ + +export { ConsoleInstrumentation } from './instrumentation'; +export type { ConsoleInstrumentationConfig, LogHookFunction } from './types'; diff --git a/packages/instrumentation-console/src/instrumentation.ts b/packages/instrumentation-console/src/instrumentation.ts new file mode 100644 index 0000000000..24fc63e360 --- /dev/null +++ b/packages/instrumentation-console/src/instrumentation.ts @@ -0,0 +1,204 @@ +/* + * Copyright The OpenTelemetry Authors + * + * 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 + * + * https://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. + */ + +import { context, trace, isSpanContextValid, Span } from '@opentelemetry/api'; +import { logs, Logger, LogAttributes } from '@opentelemetry/api-logs'; +import { + InstrumentationBase, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { + ConsoleInstrumentationConfig, + CONSOLE_METHODS, + CONSOLE_SEVERITY_TEXT, +} from './types'; +/** @knipignore */ +import { PACKAGE_NAME, PACKAGE_VERSION } from './version'; + +const DEFAULT_CONFIG: ConsoleInstrumentationConfig = { + disableLogSending: false, + disableLogCorrelation: false, +}; + +export class ConsoleInstrumentation extends InstrumentationBase { + private _otelLogger: Logger | undefined; + private _isEmitting = false; + private _originals: Map void> = new Map(); + + constructor(config: ConsoleInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, { ...DEFAULT_CONFIG, ...config }); + } + + private _getOTelLogger(): Logger { + if (!this._otelLogger) { + this._otelLogger = logs.getLogger(PACKAGE_NAME, PACKAGE_VERSION); + } + return this._otelLogger; + } + + override setConfig(config: ConsoleInstrumentationConfig = {}) { + super.setConfig({ ...DEFAULT_CONFIG, ...config }); + } + + // console is a global object, not a require'd module, so we don't use + // InstrumentationNodeModuleDefinition. Instead, we directly patch the + // global console in enable() and restore in disable(). + protected init() { + // No module definitions — we patch the global console directly. + return undefined; + } + + override enable(): void { + super.enable(); + this._patchConsole(); + } + + override disable(): void { + super.disable(); + this._unpatchConsole(); + } + + private _patchConsole(): void { + if (!this._originals) { + this._originals = new Map(); + } + for (const method of Object.keys(CONSOLE_METHODS)) { + const original = (console as any)[method]; + if (typeof original !== 'function') continue; + + // Don't double-patch + if (this._originals.has(method)) continue; + + this._originals.set(method, original); + (console as any)[method] = this._createPatchedMethod(method, original); + } + } + + private _unpatchConsole(): void { + for (const [method, original] of this._originals) { + (console as any)[method] = original; + } + this._originals.clear(); + } + + private _callHook(span: Span, record: Record) { + const { logHook } = this.getConfig(); + + if (typeof logHook !== 'function') { + return; + } + + safeExecuteInTheMiddle( + () => logHook(span, record), + err => { + if (err) { + this._diag.error('error calling logHook', err); + } + }, + true + ); + } + + private _createPatchedMethod( + methodName: string, + original: (...args: unknown[]) => void + ): (...args: unknown[]) => void { + const instrumentation = this; + return function patchedConsoleMethod( + this: Console, + ...args: unknown[] + ): void { + const config = instrumentation.getConfig(); + + if ( + !instrumentation.isEnabled() || + config.disableLogSending || + instrumentation._isEmitting + ) { + return original.apply(this, args); + } + + const severityNumber = CONSOLE_METHODS[methodName]; + if ( + config.logSeverity !== undefined && + severityNumber < config.logSeverity + ) { + return original.apply(this, args); + } + + const message = formatArgs(args); + + const attributes: LogAttributes = {}; + + // Add trace context to attributes for log correlation + // The OTel SDK also auto-populates spanContext on the LogRecord + // from the active context, so these attributes are for additional + // correlation in the log output itself. + if (!config.disableLogCorrelation) { + const span = trace.getSpan(context.active()); + if (span) { + const spanContext = span.spanContext(); + if (isSpanContextValid(spanContext)) { + attributes['trace_id'] = spanContext.traceId; + attributes['span_id'] = spanContext.spanId; + attributes['trace_flags'] = + `0${spanContext.traceFlags.toString(16)}`; + + instrumentation._callHook(span, attributes); + } + } + } + + // Set the re-entrancy guard before emitting to the OTel logger. + // This prevents infinite loops when an exporter (e.g. ConsoleLogRecordExporter) + // calls console.log() to export the record we just created. + instrumentation._isEmitting = true; + try { + const otelLogger = instrumentation._getOTelLogger(); + const timestamp = Date.now(); + otelLogger.emit({ + timestamp, + observedTimestamp: timestamp, + severityNumber, + severityText: CONSOLE_SEVERITY_TEXT[methodName], + body: message, + attributes, + }); + + return original.apply(this, args); + } finally { + instrumentation._isEmitting = false; + } + }; + } +} + +/** + * Format console arguments into a string, similar to how Node.js console does it. + */ +function formatArgs(args: unknown[]): string { + if (args.length === 0) return ''; + return args + .map(arg => { + if (typeof arg === 'string') return arg; + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } + }) + .join(' '); +} diff --git a/packages/instrumentation-console/src/types.ts b/packages/instrumentation-console/src/types.ts new file mode 100644 index 0000000000..7a01e0968e --- /dev/null +++ b/packages/instrumentation-console/src/types.ts @@ -0,0 +1,77 @@ +/* + * Copyright The OpenTelemetry Authors + * + * 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 + * + * https://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. + */ + +import { Span } from '@opentelemetry/api'; +import { SeverityNumber } from '@opentelemetry/api-logs'; +import { InstrumentationConfig } from '@opentelemetry/instrumentation'; + +export type LogHookFunction = ( + span: Span, + record: Record +) => void; + +export interface ConsoleInstrumentationConfig extends InstrumentationConfig { + /** + * Whether to disable the automatic sending of log records to the + * OpenTelemetry Logs SDK. + * @default false + */ + disableLogSending?: boolean; + + /** + * Control minimum severity level for log sending. Logs will be sent for the + * specified severity and higher. + * @default SeverityNumber.UNSPECIFIED (send all) + */ + logSeverity?: SeverityNumber; + + /** + * Whether to disable the injection of trace-context fields, and possibly + * other fields from `logHook()`, into log record attributes for log + * correlation. + * @default false + */ + disableLogCorrelation?: boolean; + + /** + * A function that allows injecting additional fields in log records. It is + * called, as `logHook(span, record)`, for each log record emitted in a valid + * span context. It requires `disableLogCorrelation` to be false. + */ + logHook?: LogHookFunction; +} + +/** Console methods to instrument and their severity mappings. */ +export const CONSOLE_METHODS: Record = { + trace: SeverityNumber.TRACE, + debug: SeverityNumber.DEBUG, + log: SeverityNumber.INFO, + info: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR, + dir: SeverityNumber.INFO, +}; + +/** Severity text labels for console methods. */ +export const CONSOLE_SEVERITY_TEXT: Record = { + trace: 'TRACE', + debug: 'DEBUG', + log: 'INFO', + info: 'INFO', + warn: 'WARN', + error: 'ERROR', + dir: 'INFO', +}; diff --git a/packages/instrumentation-console/test/console.test.ts b/packages/instrumentation-console/test/console.test.ts new file mode 100644 index 0000000000..092e80b5b8 --- /dev/null +++ b/packages/instrumentation-console/test/console.test.ts @@ -0,0 +1,272 @@ +/* + * Copyright The OpenTelemetry Authors + * + * 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 + * + * https://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. + */ + +import { + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { context, trace } from '@opentelemetry/api'; +import { logs, SeverityNumber } from '@opentelemetry/api-logs'; +import { + LoggerProvider, + SimpleLogRecordProcessor, + InMemoryLogRecordExporter, +} from '@opentelemetry/sdk-logs'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import * as assert from 'assert'; +import { ConsoleInstrumentation } from '../src'; + +const tracerProvider = new NodeTracerProvider({ + spanProcessors: [new SimpleSpanProcessor(new InMemorySpanExporter())], +}); +tracerProvider.register(); +const tracer = tracerProvider.getTracer('default'); + +const memExporter = new InMemoryLogRecordExporter(); +const loggerProvider = new LoggerProvider({ + processors: [new SimpleLogRecordProcessor(memExporter)], +}); +logs.setGlobalLoggerProvider(loggerProvider); + +const instrumentation = new ConsoleInstrumentation(); + +describe('ConsoleInstrumentation', () => { + beforeEach(() => { + instrumentation.setConfig({}); + memExporter.getFinishedLogRecords().length = 0; + }); + + describe('log sending', () => { + it('emits LogRecord for console.log', () => { + console.log('hello world'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'hello world'); + assert.strictEqual(logRecords[0].severityNumber, SeverityNumber.INFO); + assert.strictEqual(logRecords[0].severityText, 'INFO'); + }); + + it('emits LogRecord for console.info', () => { + console.info('info message'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'info message'); + assert.strictEqual(logRecords[0].severityNumber, SeverityNumber.INFO); + assert.strictEqual(logRecords[0].severityText, 'INFO'); + }); + + it('emits LogRecord for console.warn', () => { + console.warn('warning message'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'warning message'); + assert.strictEqual(logRecords[0].severityNumber, SeverityNumber.WARN); + assert.strictEqual(logRecords[0].severityText, 'WARN'); + }); + + it('emits LogRecord for console.error', () => { + console.error('error message'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'error message'); + assert.strictEqual(logRecords[0].severityNumber, SeverityNumber.ERROR); + assert.strictEqual(logRecords[0].severityText, 'ERROR'); + }); + + it('emits LogRecord for console.debug', () => { + console.debug('debug message'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'debug message'); + assert.strictEqual(logRecords[0].severityNumber, SeverityNumber.DEBUG); + assert.strictEqual(logRecords[0].severityText, 'DEBUG'); + }); + + it('emits LogRecord for console.trace', () => { + console.trace('trace message'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'trace message'); + assert.strictEqual(logRecords[0].severityNumber, SeverityNumber.TRACE); + assert.strictEqual(logRecords[0].severityText, 'TRACE'); + }); + + it('formats multiple arguments', () => { + console.log('hello', 'world', 42); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'hello world 42'); + }); + + it('formats object arguments as JSON', () => { + console.log('data:', { key: 'value' }); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'data: {"key":"value"}'); + }); + }); + + describe('trace context correlation', () => { + it('includes trace context in attributes when span is active', () => { + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + const { traceId, spanId, traceFlags } = span.spanContext(); + console.log('in span'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].attributes['trace_id'], traceId); + assert.strictEqual(logRecords[0].attributes['span_id'], spanId); + assert.strictEqual( + logRecords[0].attributes['trace_flags'], + `0${traceFlags.toString(16)}` + ); + // spanContext is auto-populated by the OTel SDK + assert.strictEqual(logRecords[0].spanContext?.traceId, traceId); + assert.strictEqual(logRecords[0].spanContext?.spanId, spanId); + assert.strictEqual(logRecords[0].spanContext?.traceFlags, traceFlags); + }); + span.end(); + }); + + it('does not include trace context when no span is active', () => { + console.log('no span'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].attributes['trace_id'], undefined); + assert.strictEqual(logRecords[0].attributes['span_id'], undefined); + assert.strictEqual(logRecords[0].attributes['trace_flags'], undefined); + assert.strictEqual(logRecords[0].spanContext, undefined); + }); + + it('does not inject trace context when disableLogCorrelation=true', () => { + instrumentation.setConfig({ disableLogCorrelation: true }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + console.log('no correlation'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].attributes['trace_id'], undefined); + assert.strictEqual(logRecords[0].attributes['span_id'], undefined); + assert.strictEqual(logRecords[0].attributes['trace_flags'], undefined); + // SDK still populates spanContext since emit happens in active context + assert.strictEqual( + logRecords[0].spanContext?.traceId, + span.spanContext().traceId + ); + }); + span.end(); + }); + }); + + describe('configuration', () => { + it('does not emit LogRecords when disableLogSending is true', () => { + instrumentation.setConfig({ disableLogSending: true }); + console.log('should not be sent'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 0); + }); + + it('respects logSeverity filter', () => { + instrumentation.setConfig({ logSeverity: SeverityNumber.WARN }); + console.log('info log'); + console.warn('warn log'); + console.error('error log'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 2); + assert.strictEqual(logRecords[0].body, 'warn log'); + assert.strictEqual(logRecords[1].body, 'error log'); + }); + + it('calls logHook and includes returned attributes', () => { + instrumentation.setConfig({ + logHook: (_span, record) => { + record['custom.field'] = 'test-value'; + }, + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + console.warn('test hook'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual( + logRecords[0].attributes['custom.field'], + 'test-value' + ); + }); + span.end(); + }); + + it('does not call logHook when no span is active', () => { + let hookCalled = false; + instrumentation.setConfig({ + logHook: () => { + hookCalled = true; + }, + }); + console.log('no span'); + assert.strictEqual(hookCalled, false); + }); + + it('does not call logHook when disableLogCorrelation=true', () => { + let hookCalled = false; + instrumentation.setConfig({ + disableLogCorrelation: true, + logHook: () => { + hookCalled = true; + }, + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + console.log('no hook'); + assert.strictEqual(hookCalled, false); + // Log sending still works + assert.strictEqual(memExporter.getFinishedLogRecords().length, 1); + }); + span.end(); + }); + + it('does not propagate exceptions from logHook', () => { + instrumentation.setConfig({ + logHook: () => { + throw new Error('hook error'); + }, + }); + const span = tracer.startSpan('test-span'); + context.with(trace.setSpan(context.active(), span), () => { + console.log('test'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 1); + assert.strictEqual(logRecords[0].body, 'test'); + // Trace context still populated despite hook error + assert.strictEqual( + logRecords[0].attributes['trace_id'], + span.spanContext().traceId + ); + }); + span.end(); + }); + }); + + describe('disabled instrumentation', () => { + it('does not emit LogRecords when disabled', () => { + instrumentation.disable(); + console.log('should not be sent'); + const logRecords = memExporter.getFinishedLogRecords(); + assert.strictEqual(logRecords.length, 0); + instrumentation.enable(); + }); + }); +}); diff --git a/packages/instrumentation-console/tsconfig.json b/packages/instrumentation-console/tsconfig.json new file mode 100644 index 0000000000..4078877ce6 --- /dev/null +++ b/packages/instrumentation-console/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": ".", + "outDir": "build" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/release-please-config.json b/release-please-config.json index b64af7a2f3..4920d5df30 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -43,6 +43,7 @@ "packages/instrumentation-aws-sdk": {}, "packages/instrumentation-bunyan": {}, "packages/instrumentation-cassandra-driver": {}, + "packages/instrumentation-console": {}, "packages/instrumentation-connect": {}, "packages/instrumentation-dns": {}, "packages/instrumentation-express": {},