Skip to content

Commit

Permalink
feat(instrumentation-tls): wrap tls.connect API (open-telemetry#447)
Browse files Browse the repository at this point in the history
  • Loading branch information
svrnm authored May 7, 2021
1 parent 1121ac9 commit 22fab71
Show file tree
Hide file tree
Showing 6 changed files with 491 additions and 6 deletions.
51 changes: 51 additions & 0 deletions examples/network/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict';

const { diag, DiagConsoleLogger, DiagLogLevel } = require('@opentelemetry/api');
const { NodeTracerProvider } = require('@opentelemetry/node');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { NetInstrumentation } = require('@opentelemetry/instrumentation-net');
const { DnsInstrumentation } = require('@opentelemetry/instrumentation-dns');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
const { SimpleSpanProcessor, ConsoleSpanExporter } = require('@opentelemetry/tracing');
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');

const provider = new NodeTracerProvider();

provider.addSpanProcessor(new SimpleSpanProcessor(new JaegerExporter({
serviceName: 'http-client',
})));

provider.addSpanProcessor(new SimpleSpanProcessor(new ConsoleSpanExporter()));

provider.register();

diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ALL);

registerInstrumentations({
instrumentations: [
new NetInstrumentation(),
new HttpInstrumentation(),
new DnsInstrumentation({
// Avoid dns lookup loop with http zipkin calls
ignoreHostnames: ['localhost'],
}),
],
tracerProvider: provider,
});

require('net');
require('dns');
const https = require('https');
const http = require('http');

http.get('http://opentelemetry.io/', () => {}).on('error', (e) => {
console.error(e);
});

https.get('https://opentelemetry.io/', () => {}).on('error', (e) => {
console.error(e);
});

https.get('https://opentelemetry.io/', { ca: [] }, () => {}).on('error', (e) => {
console.error(e);
});
44 changes: 44 additions & 0 deletions examples/network/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "tls-example",
"private": true,
"version": "0.15.0",
"description": "Example of NET & TLS integration with OpenTelemetry",
"main": "index.js",
"scripts": {
"zipkin:client": "cross-env EXPORTER=zipkin node ./client.js",
"jaeger:client": "cross-env EXPORTER=jaeger node ./client.js"
},
"repository": {
"type": "git",
"url": "git+ssh://[email protected]/open-telemetry/opentelemetry-js-contrib.git"
},
"keywords": [
"opentelemetry",
"net",
"tls",
"tracing"
],
"engines": {
"node": ">=8.5.0"
},
"author": "OpenTelemetry Authors",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/open-telemetry/opentelemetry-js-contrib/issues"
},
"dependencies": {
"@opentelemetry/api": "^0.18.1",
"@opentelemetry/exporter-jaeger": "^0.18.2",
"@opentelemetry/exporter-zipkin": "^0.18.2",
"@opentelemetry/instrumentation": "^0.18.2",
"@opentelemetry/instrumentation-net": "file:../../plugins/node/opentelemetry-instrumentation-net",
"@opentelemetry/instrumentation-http": "^0.19.0",
"@opentelemetry/instrumentation-dns": "^0.15.0",
"@opentelemetry/node": "^0.18.2",
"@opentelemetry/tracing": "^0.18.2"
},
"homepage": "https://github.com/open-telemetry/opentelemetry-js-contrib#readme",
"devDependencies": {
"cross-env": "^6.0.3"
}
}
83 changes: 77 additions & 6 deletions plugins/node/opentelemetry-instrumentation-net/src/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ import {
SemanticAttributes,
NetTransportValues,
} from '@opentelemetry/semantic-conventions';
import { Net, NormalizedOptions, SocketEvent } from './types';
import { Net, NormalizedOptions, SocketEvent, TLSAttributes } from './types';
import { getNormalizedArgs, IPC_TRANSPORT } from './utils';
import { VERSION } from './version';
import { Socket } from 'net';
import { TLSSocket } from 'tls';

export class NetInstrumentation extends InstrumentationBase<Net> {
constructor(protected _config: InstrumentationConfig = {}) {
Expand Down Expand Up @@ -69,11 +70,10 @@ export class NetInstrumentation extends InstrumentationBase<Net> {
return function patchedConnect(this: Socket, ...args: unknown[]) {
const options = getNormalizedArgs(args);

const span = options
? options.path
? plugin._startIpcSpan(options, this)
: plugin._startTcpSpan(options, this)
: plugin._startGenericSpan(this);
const span =
this instanceof TLSSocket
? plugin._startTLSSpan(options, this)
: plugin._startSpan(options, this);

return safeExecuteInTheMiddle(
() => original.apply(this, args),
Expand All @@ -92,6 +92,77 @@ export class NetInstrumentation extends InstrumentationBase<Net> {
};
}

private _startSpan(
options: NormalizedOptions | undefined | null,
socket: Socket
) {
if (!options) {
return this._startGenericSpan(socket);
}
if (options.path) {
return this._startIpcSpan(options, socket);
}
return this._startTcpSpan(options, socket);
}

private _startTLSSpan(
options: NormalizedOptions | undefined | null,
socket: TLSSocket
) {
const tlsSpan = this.tracer.startSpan('tls.connect');

const netSpan = this._startSpan(options, socket);

const otelTlsSpanListener = () => {
const peerCertificate = socket.getPeerCertificate(true);
const cipher = socket.getCipher();
const protocol = socket.getProtocol();
const attributes = {
[TLSAttributes.PROTOCOL]: String(protocol),
[TLSAttributes.AUTHORIZED]: String(socket.authorized),
[TLSAttributes.CIPHER_NAME]: cipher.name,
[TLSAttributes.CIPHER_VERSION]: cipher.version,
[TLSAttributes.CERTIFICATE_FINGERPRINT]: peerCertificate.fingerprint,
[TLSAttributes.CERTIFICATE_SERIAL_NUMBER]: peerCertificate.serialNumber,
[TLSAttributes.CERTIFICATE_VALID_FROM]: peerCertificate.valid_from,
[TLSAttributes.CERTIFICATE_VALID_TO]: peerCertificate.valid_to,
[TLSAttributes.ALPN_PROTOCOL]: '',
};
if (socket.alpnProtocol) {
attributes[TLSAttributes.ALPN_PROTOCOL] = socket.alpnProtocol;
}

tlsSpan.setAttributes(attributes);
tlsSpan.end();
};

const otelTlsErrorListener = (e: Error) => {
tlsSpan.setStatus({
code: SpanStatusCode.ERROR,
message: e.message,
});
tlsSpan.end();
};

/* if we use once and tls.connect() uses a callback this is never executed */
socket.prependOnceListener(SocketEvent.SECURE_CONNECT, otelTlsSpanListener);
socket.once(SocketEvent.ERROR, otelTlsErrorListener);

const otelTlsRemoveListeners = () => {
socket.removeListener(SocketEvent.SECURE_CONNECT, otelTlsSpanListener);
socket.removeListener(SocketEvent.ERROR, otelTlsErrorListener);
for (const event of SOCKET_EVENTS) {
socket.removeListener(event, otelTlsRemoveListeners);
}
};

for (const event of [SocketEvent.CLOSE, SocketEvent.ERROR]) {
socket.once(event, otelTlsRemoveListeners);
}

return netSpan;
}

/* It might still be useful to pick up errors due to invalid connect arguments. */
private _startGenericSpan(socket: Socket) {
const span = this.tracer.startSpan('connect');
Expand Down
14 changes: 14 additions & 0 deletions plugins/node/opentelemetry-instrumentation-net/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,18 @@ export enum SocketEvent {
CLOSE = 'close',
CONNECT = 'connect',
ERROR = 'error',
SECURE_CONNECT = 'secureConnect',
}

/* The following attributes are not offical, see open-telemetry/opentelemetry-specification#1652 */
export enum TLSAttributes {
PROTOCOL = 'tls.protocol',
AUTHORIZED = 'tls.authorized',
CIPHER_NAME = 'tls.cipher.name',
CIPHER_VERSION = 'tls.cipher.version',
CERTIFICATE_FINGERPRINT = 'tls.certificate.fingerprint',
CERTIFICATE_SERIAL_NUMBER = 'tls.certificate.serialNumber',
CERTIFICATE_VALID_FROM = 'tls.certificate.validFrom',
CERTIFICATE_VALID_TO = 'tls.certificate.validTo',
ALPN_PROTOCOL = 'tls.alpnProtocol',
}
172 changes: 172 additions & 0 deletions plugins/node/opentelemetry-instrumentation-net/test/tls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* 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 { SpanStatusCode } from '@opentelemetry/api';
import {
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/tracing';
import { NodeTracerProvider } from '@opentelemetry/node';
import * as assert from 'assert';
import * as tls from 'tls';
import { NetInstrumentation } from '../src/net';
import { SocketEvent } from '../src/types';
import {
assertTLSSpan,
HOST,
TLS_SERVER_CERT,
TLS_SERVER_KEY,
PORT,
} from './utils';

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

function getTLSSpans() {
const spans = memoryExporter.getFinishedSpans();
assert.strictEqual(spans.length, 2);
const [netSpan, tlsSpan] = spans;
return {
netSpan,
tlsSpan,
};
}

describe('NetInstrumentation', () => {
let instrumentation: NetInstrumentation;
let tlsServer: tls.Server;
let tlsSocket: tls.TLSSocket;

before(() => {
instrumentation = new NetInstrumentation();
instrumentation.setTracerProvider(provider);
require('net');
});

before(done => {
tlsServer = tls.createServer({
cert: TLS_SERVER_CERT,
key: TLS_SERVER_KEY,
// Make sure tests run on nodejs v8 and v10 the same as on v12+
maxVersion: 'TLSv1.2',
});
tlsServer.listen(PORT, done);
});

afterEach(() => {
memoryExporter.reset();
tlsSocket.destroy();
});

after(() => {
instrumentation.disable();
tlsServer.close();
});

describe('successful tls.connect produces a span', () => {
it('should produce a span with "onSecure" callback', done => {
tlsSocket = tls.connect(
PORT,
HOST,
{
ca: [TLS_SERVER_CERT],
checkServerIdentity: () => {
return undefined;
},
},
() => {
assertTLSSpan(getTLSSpans(), tlsSocket);
done();
}
);
});

it('should produce a span without "onSecure" callback', done => {
tlsSocket = tls.connect(PORT, HOST, {
ca: [TLS_SERVER_CERT],
checkServerIdentity: () => {
return undefined;
},
});
tlsServer.once('secureConnection', c => {
c.end();
});
tlsSocket.once('end', () => {
assertTLSSpan(getTLSSpans(), tlsSocket);
done();
});
});

it('should produce an error span when certificate is not trusted', done => {
tlsSocket = tls.connect(
PORT,
HOST,
{
ca: [],
checkServerIdentity: () => {
return undefined;
},
},
() => {
assertTLSSpan(getTLSSpans(), tlsSocket);
done();
}
);
tlsSocket.on('error', error => {
const { tlsSpan } = getTLSSpans();
assert.strictEqual(tlsSpan.status.message, 'self signed certificate');
assert.strictEqual(tlsSpan.status.code, SpanStatusCode.ERROR);
done();
});
});
});

describe('cleanup', () => {
function assertNoDanglingListeners(tlsSocket: tls.TLSSocket) {
const events = new Set(tlsSocket.eventNames());

for (const event of [
SocketEvent.CONNECT,
SocketEvent.SECURE_CONNECT,
SocketEvent.ERROR,
]) {
assert.equal(events.has(event), false);
}
assert.strictEqual(tlsSocket.listenerCount(SocketEvent.CLOSE), 1);
}

it('should clean up listeners for tls.connect', done => {
tlsSocket = tls.connect(
PORT,
HOST,
{
ca: [TLS_SERVER_CERT],
checkServerIdentity: () => {
return undefined;
},
},
() => {
tlsSocket.destroy();
tlsSocket.once(SocketEvent.CLOSE, () => {
assertNoDanglingListeners(tlsSocket);
done();
});
}
);
});
});
});
Loading

0 comments on commit 22fab71

Please sign in to comment.