Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(instrumentation-http): Ensure instrumentation of http.get and https.get work when used in ESM code #4866

Merged
merged 11 commits into from
Jul 24, 2024
2 changes: 2 additions & 0 deletions experimental/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ All notable changes to experimental packages in this project will be documented

### :bug: (Bug Fix)

* fix(instrumentation-http): Ensure instrumentation of `http.get` and `https.get` work when used in ESM code [#4857](https://github.com/open-telemetry/opentelemetry-js/issues/4857) @trentm

### :books: (Refine Doc)

### :house: (Internal)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
"prepublishOnly": "npm run compile",
"compile": "tsc --build",
"clean": "tsc --build --clean",
"test": "nyc mocha test/**/*.test.ts",
"test:cjs": "nyc mocha test/**/*.test.ts",
"test:esm": "nyc node --experimental-loader=@opentelemetry/instrumentation/hook.mjs ../../../node_modules/mocha/bin/mocha 'test/**/*.test.mjs'",
"test": "npm run test:cjs && npm run test:esm",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer note: This test:esm script follows the same pattern used for ESM tests in opentelemetry-instrumentation.

"tdd": "npm run test -- --watch-extensions ts --watch",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,15 @@ export class HttpInstrumentation extends InstrumentationBase<HttpInstrumentation
'http',
['*'],
(moduleExports: Http): Http => {
this._wrap(
const patchedRequest = this._wrap(
moduleExports,
'request',
this._getPatchOutgoingRequestFunction('http')
);
) as unknown as Func<http.ClientRequest>;
this._wrap(
moduleExports,
'get',
this._getPatchOutgoingGetFunction(moduleExports.request)
this._getPatchOutgoingGetFunction(patchedRequest)
);
this._wrap(
moduleExports.Server.prototype,
Expand All @@ -142,16 +142,17 @@ export class HttpInstrumentation extends InstrumentationBase<HttpInstrumentation
'https',
['*'],
(moduleExports: Https): Https => {
this._wrap(
const patchedRequest = this._wrap(
moduleExports,
'request',
this._getPatchHttpsOutgoingRequestFunction('https')
);
) as unknown as Func<http.ClientRequest>;
this._wrap(
moduleExports,
'get',
this._getPatchHttpsOutgoingGetFunction(moduleExports.request)
this._getPatchHttpsOutgoingGetFunction(patchedRequest)
);

this._wrap(
moduleExports.Server.prototype,
'emit',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/*
* 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.
*/

// Test that instrumentation-http works when used from ESM code.

import * as assert from 'assert';
import * as fs from 'fs';
import * as http from 'http';
import * as https from 'https';

import { SpanKind } from '@opentelemetry/api';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
import {
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';

import { assertSpan } from '../../build/test/utils/assertSpan.js';
import { HttpInstrumentation } from '../../build/src/index.js';

const provider = new NodeTracerProvider();
const memoryExporter = new InMemorySpanExporter();
provider.addSpanProcessor(new SimpleSpanProcessor(memoryExporter));
const instrumentation = new HttpInstrumentation();
instrumentation.setTracerProvider(provider);

describe('HttpInstrumentation ESM Integration tests', () => {
let port;
let server;

before(done => {
server = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200);
res.end('pong');
});
});

server.listen(0, '127.0.0.1', () => {
port = server.address().port;
assert.ok(Number.isInteger(port));
done();
});
});

after(done => {
server.close(done);
});

beforeEach(() => {
memoryExporter.reset();
});

it('http.request', async () => {
trentm marked this conversation as resolved.
Show resolved Hide resolved
const spanValidations = {
httpStatusCode: 200,
httpMethod: 'GET',
hostname: '127.0.0.1',
pathname: '/http.request',
component: 'http',
};

await new Promise(resolve => {
const creq = http.request(
`http://127.0.0.1:${port}/http.request`,
cres => {
trentm marked this conversation as resolved.
Show resolved Hide resolved
spanValidations.resHeaders = cres.headers;
cres.resume();
cres.on('end', resolve);
}
);
creq.end();
});

let spans = memoryExporter.getFinishedSpans();
assert.strictEqual(spans.length, 2);
const span = spans.find(s => s.kind === SpanKind.CLIENT);
assert.strictEqual(span.name, 'GET');
assertSpan(span, SpanKind.CLIENT, spanValidations);
});

it('http.get', async () => {
trentm marked this conversation as resolved.
Show resolved Hide resolved
const spanValidations = {
httpStatusCode: 200,
httpMethod: 'GET',
hostname: '127.0.0.1',
pathname: '/http.get',
component: 'http',
};

await new Promise(resolve => {
http.get(`http://127.0.0.1:${port}/http.get`, cres => {
spanValidations.resHeaders = cres.headers;
cres.resume();
cres.on('end', resolve);
});
});

let spans = memoryExporter.getFinishedSpans();
assert.strictEqual(spans.length, 2);
const span = spans.find(s => s.kind === SpanKind.CLIENT);
assert.strictEqual(span.name, 'GET');
assertSpan(span, SpanKind.CLIENT, spanValidations);
});
});

describe('HttpsInstrumentation ESM Integration tests', () => {
let port;
let server;

before(done => {
server = https.createServer(
{
key: fs.readFileSync(
new URL('../fixtures/server-key.pem', import.meta.url)
),
cert: fs.readFileSync(
new URL('../fixtures/server-cert.pem', import.meta.url)
),
},
(req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200);
res.end('pong');
});
}
);

server.listen(0, '127.0.0.1', () => {
port = server.address().port;
assert.ok(Number.isInteger(port));
done();
});
});

after(done => {
server.close(done);
});

beforeEach(() => {
memoryExporter.reset();
});

it('https.request', async () => {
trentm marked this conversation as resolved.
Show resolved Hide resolved
const spanValidations = {
httpStatusCode: 200,
httpMethod: 'GET',
hostname: '127.0.0.1',
pathname: '/https.request',
component: 'https',
};

await new Promise(resolve => {
const creq = https.request(
`https://127.0.0.1:${port}/https.request`,
{
rejectUnauthorized: false,
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Show resolved Hide resolved
Dismissed Show dismissed Hide dismissed
},
cres => {
spanValidations.resHeaders = cres.headers;
cres.resume();
cres.on('end', resolve);
}
);
creq.end();
});

let spans = memoryExporter.getFinishedSpans();
assert.strictEqual(spans.length, 2);
const span = spans.find(s => s.kind === SpanKind.CLIENT);
assert.strictEqual(span.name, 'GET');
assertSpan(span, SpanKind.CLIENT, spanValidations);
});

it('https.get', async () => {
trentm marked this conversation as resolved.
Show resolved Hide resolved
const spanValidations = {
httpStatusCode: 200,
httpMethod: 'GET',
hostname: '127.0.0.1',
pathname: '/https.get',
component: 'https',
};

await new Promise(resolve => {
https.get(
`https://127.0.0.1:${port}/https.get`,
{
rejectUnauthorized: false,
Fixed Show fixed Hide fixed
Dismissed Show dismissed Hide dismissed
},
cres => {
spanValidations.resHeaders = cres.headers;
cres.resume();
cres.on('end', resolve);
}
);
});

let spans = memoryExporter.getFinishedSpans();
assert.strictEqual(spans.length, 2);
const span = spans.find(s => s.kind === SpanKind.CLIENT);
assert.strictEqual(span.name, 'GET');
assertSpan(span, SpanKind.CLIENT, spanValidations);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"tdd:node": "npm run test -- --watch-extensions ts --watch",
"tdd:browser": "karma start",
"test:cjs": "nyc mocha 'test/**/*.test.ts' --exclude 'test/browser/**/*.ts'",
"test:esm": "nyc node --experimental-loader=./hook.mjs ../../../node_modules/mocha/bin/mocha 'test/node/*.test.mjs' test/node/*.test.mjs",
"test:esm": "nyc node --experimental-loader=./hook.mjs ../../../node_modules/mocha/bin/mocha 'test/node/*.test.mjs'",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer note: I think this was a typo from when this was originally added. The additional test/node/*.test.mjs arg is redundant.

"test": "npm run test:cjs && npm run test:esm",
"test:browser": "karma start --single-run",
"version": "node ../../../scripts/version-update.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ export abstract class InstrumentationBase<
return wrap(moduleExports, name, wrapper);
} else {
const wrapped = wrap(Object.assign({}, moduleExports), name, wrapper);

return Object.defineProperty(moduleExports, name, {
Object.defineProperty(moduleExports, name, {
value: wrapped,
});
return wrapped;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer note: From a quick check, I didn't see any core or contrib-repo instrumentations using the return value of this._wrap. Arguably this is a "fix" for @opentelemetry/instrumentation, but I didn't mention it in the CHANGELOG.

}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,21 @@ class TestInstrumentationWrapFn extends InstrumentationBase {
super('test-esm-instrumentation', '0.0.1', config);
}
init() {
console.log('test-esm-instrumentation initialized!');
JamieDanielson marked this conversation as resolved.
Show resolved Hide resolved
return new InstrumentationNodeModuleDefinition(
'test-esm-module',
['*'],
moduleExports => {
this._wrap(moduleExports, 'testFunction', () => {
return () => 'patched';
const wrapRetval = this._wrap(moduleExports, 'testFunction', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const wrapRetval = this._wrap(moduleExports, 'testFunction', () => {
const wrappedReturnValue = this._wrap(moduleExports, 'testFunction', () => {

This is a total nit, but I wonder if we could use a full name for this - primarily because this tends to be a confusing functionality and I'm a sucker for detailed variable names. Maybe something like patchedModule, similar to the patchedRequest you have in the http code? Nonblocking, especially since it's a test.

return function wrappedTestFunction() {
return 'patched';
};
});
assert.strictEqual(typeof wrapRetval, 'function');
assert.strictEqual(
wrapRetval.name,
'wrappedTestFunction',
'_wrap(..., "testFunction", ...) return value is the wrapped function'
);
return moduleExports;
},
moduleExports => {
Expand Down