diff --git a/.gitmodules b/.gitmodules index 33a9a7d9b21..e74e3dbe001 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "experimental/packages/otlp-proto-exporter-base/protos"] path = experimental/packages/otlp-proto-exporter-base/protos url = https://github.com/open-telemetry/opentelemetry-proto.git +[submodule "experimental/packages/exporter-trace-otlp-grpc/protos"] + path = experimental/packages/exporter-trace-otlp-grpc/protos + url = https://github.com/open-telemetry/opentelemetry-proto diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index f0f47c69e52..a0315f553e0 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -19,6 +19,8 @@ All notable changes to experimental packages in this project will be documented ### :bug: (Bug Fix) * fix(histogram): fix maximum when only values < -1 are provided [#3086](https://github.com/open-telemetry/opentelemetry-js/pull/3086) @pichlermarc +* fix(exporter-grpc): prevent trace and metrics grpc service clients from interfering with each other [#3100](https://github.com/open-telemetry/opentelemetry-js/pull/3100) @dyladan + * refactor trace grpc exporter to not use the grpc exporter base class * fix(sdk-metrics-base): fix PeriodicExportingMetricReader keeping Node.js process from exiting [#3106](https://github.com/open-telemetry/opentelemetry-js/pull/3106) @seemk diff --git a/experimental/packages/exporter-trace-otlp-grpc/package.json b/experimental/packages/exporter-trace-otlp-grpc/package.json index 8412dd8eda5..56a07df11b1 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/package.json +++ b/experimental/packages/exporter-trace-otlp-grpc/package.json @@ -49,7 +49,7 @@ "devDependencies": { "@babel/core": "7.16.0", "@opentelemetry/api": "^1.0.0", - "@opentelemetry/otlp-exporter-base": "0.30.0", + "@opentelemetry/resources": "1.4.0", "@types/mocha": "8.2.3", "@types/node": "14.17.33", "@types/sinon": "10.0.6", @@ -67,12 +67,10 @@ "@opentelemetry/api": "^1.0.0" }, "dependencies": { - "@grpc/grpc-js": "^1.5.9", - "@grpc/proto-loader": "^0.6.9", + "@grpc/grpc-js": "1.5.9", + "@grpc/proto-loader": "0.6.9", "@opentelemetry/core": "1.4.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.30.0", "@opentelemetry/otlp-transformer": "0.30.0", - "@opentelemetry/resources": "1.4.0", "@opentelemetry/sdk-trace-base": "1.4.0" } } diff --git a/experimental/packages/exporter-trace-otlp-grpc/protos b/experimental/packages/exporter-trace-otlp-grpc/protos new file mode 160000 index 00000000000..c5c8b280125 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/protos @@ -0,0 +1 @@ +Subproject commit c5c8b28012583fda55b0cb16f73a820722171d49 diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts b/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts index f4b0554715f..5bec8dda8b3 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts +++ b/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts @@ -14,58 +14,100 @@ * limitations under the License. */ -import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; -import { baggageUtils, getEnv } from '@opentelemetry/core'; -import { Metadata } from '@grpc/grpc-js'; -import { - OTLPGRPCExporterConfigNode, - OTLPGRPCExporterNodeBase, - ServiceClientType, - validateAndNormalizeUrl, - DEFAULT_COLLECTOR_URL -} from '@opentelemetry/otlp-grpc-exporter-base'; -import { createExportTraceServiceRequest, IExportTraceServiceRequest } from '@opentelemetry/otlp-transformer'; +import * as grpc from '@grpc/grpc-js'; +import { loadSync } from '@grpc/proto-loader'; +import { ExportResult, ExportResultCode, getEnv } from '@opentelemetry/core'; +import { createExportTraceServiceRequest } from '@opentelemetry/otlp-transformer'; +import type { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; +import * as path from 'path'; +import { RequestCounter } from './internal/request-counter'; +import { TraceServiceClient } from './internal/types'; +import type { OTLPGRPCTraceExporterConfig } from './types'; +import { getConnectionOptions } from './util'; /** * OTLP Trace Exporter for Node */ -export class OTLPTraceExporter - extends OTLPGRPCExporterNodeBase - implements SpanExporter { +export class OTLPTraceExporter implements SpanExporter { + private _metadata: grpc.Metadata; + private _serviceClient: TraceServiceClient; + private _timeoutMillis: number; + private _requestCounter = new RequestCounter(); + private _isShutdown = false; - constructor(config: OTLPGRPCExporterConfigNode = {}) { - super(config); - const headers = baggageUtils.parseKeyPairsIntoRecord(getEnv().OTEL_EXPORTER_OTLP_TRACES_HEADERS); - this.metadata ||= new Metadata(); - for (const [k, v] of Object.entries(headers)) { - this.metadata.set(k, v); - } - } + constructor(config: OTLPGRPCTraceExporterConfig = {}) { + const { host, credentials, metadata, compression } = getConnectionOptions(config, getEnv()); + this._metadata = metadata; + this._timeoutMillis = config.timeoutMillis ?? 10_000; - convert(spans: ReadableSpan[]): IExportTraceServiceRequest { - return createExportTraceServiceRequest(spans); - } + const packageDefinition = loadSync('opentelemetry/proto/collector/trace/v1/trace_service.proto', { + keepCase: false, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [ + // In webpack environments, the bundle may be at the same level as the protos/ directory + path.resolve(__dirname, 'protos'), + // When running typescript directly in tests or with ts-node, the protos/ directory is one level above the src/ directory + path.resolve(__dirname, '..', 'protos'), + // When running the compiled `js, the protos directory is two levels above the build/src/ directory + path.resolve(__dirname, '..', '..', 'protos'), + ], + }); - getDefaultUrl(config: OTLPGRPCExporterConfigNode) { - return validateAndNormalizeUrl(this.getUrlFromConfig(config)); + // any required here because the types returned by loadSync don't know our package structure + const packageObject: any = grpc.loadPackageDefinition(packageDefinition); + const channelOptions: grpc.ChannelOptions = { + 'grpc.default_compression_algorithm': compression, + }; + this._serviceClient = new packageObject.opentelemetry.proto.collector.trace.v1.TraceService(host, credentials, channelOptions); } - getServiceClientType() { - return ServiceClientType.SPANS; - } + export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { + if (this._isShutdown) { + setImmediate(resultCallback, { + code: ExportResultCode.FAILED, + error: new Error('Cannot export after shutdown'), + }); + return; + } - getServiceProtoPath(): string { - return 'opentelemetry/proto/collector/trace/v1/trace_service.proto'; + const deadline = Date.now() + this._timeoutMillis; + this._requestCounter.startRequest(); + + const req = createExportTraceServiceRequest(spans); + this._serviceClient.export( + req, + this._metadata, + { deadline }, + (error: Error) => { + this._requestCounter.endRequest(); + + if (error) { + resultCallback({ + code: ExportResultCode.FAILED, + error, + }); + } else { + resultCallback({ + code: ExportResultCode.SUCCESS, + }); + } + }, + ); } - getUrlFromConfig(config: OTLPGRPCExporterConfigNode): string { - if (typeof config.url === 'string') { - return config.url; - } + shutdown(): Promise { + this._isShutdown = true; + return new Promise((resolve, _reject) => { + if (this._requestCounter.requests === 0) { + resolve(); + return; + } - return getEnv().OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || - getEnv().OTEL_EXPORTER_OTLP_ENDPOINT || - DEFAULT_COLLECTOR_URL; + this._requestCounter.onEndLastRequest(resolve); + }); } } + diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/index.ts b/experimental/packages/exporter-trace-otlp-grpc/src/index.ts index 761e8a92624..2814d6f7184 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/src/index.ts +++ b/experimental/packages/exporter-trace-otlp-grpc/src/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ -export * from './OTLPTraceExporter'; +export { OTLPTraceExporter } from './OTLPTraceExporter'; +export { CompressionAlgorithm, OTLPGRPCTraceExporterConfig } from './types'; diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/internal/request-counter.ts b/experimental/packages/exporter-trace-otlp-grpc/src/internal/request-counter.ts new file mode 100644 index 00000000000..816b71d5cc1 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/internal/request-counter.ts @@ -0,0 +1,48 @@ +/* + * 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 EventEmitter = require('events'); + +export class RequestCounter { + public requests = 0; + private _e = new EventEmitter(); + + startRequest() { + this.requests += 1; + } + + endRequest() { + if (this.requests === 0) { + throw new Error('Tried to end more requests than were started'); + } + this.requests -= 1; + this._e.emit('endRequest'); + } + + onEndLastRequest(cb: () => void) { + if (this.requests === 0) { + setImmediate(cb); + } + + const self = this; + this._e.on('endRequest', function onEndRequest() { + if (self.requests === 0) { + self._e.removeListener('endRequest', onEndRequest); + cb(); + } + }); + } +} diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/internal/types.ts b/experimental/packages/exporter-trace-otlp-grpc/src/internal/types.ts new file mode 100644 index 00000000000..5aec1084b43 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/internal/types.ts @@ -0,0 +1,34 @@ +/* + * 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 type * as grpc from '@grpc/grpc-js'; + +export interface TraceServiceClient extends grpc.Client { + export: ( + request: any, + metadata: grpc.Metadata, + options: grpc.CallOptions, + callback: Function + ) => {}; +} + + +export type ConnectionOptions = { + host: string; + metadata: grpc.Metadata; + credentials: grpc.ChannelCredentials; + compression: grpc.compressionAlgorithms; +}; diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/types.ts b/experimental/packages/exporter-trace-otlp-grpc/src/types.ts new file mode 100644 index 00000000000..eae35496170 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/types.ts @@ -0,0 +1,33 @@ +/* + * 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 type * as grpc from '@grpc/grpc-js'; + +export type OTLPGRPCTraceExporterConfig = { + credentials?: grpc.ChannelCredentials; + metadata?: grpc.Metadata; + compression?: CompressionAlgorithm; + + url?: string; + /** Maximum time the OTLP exporter will wait for each batch export. + * The default value is 10000ms. */ + timeoutMillis?: number; +}; + +export enum CompressionAlgorithm { + NONE = 'none', + GZIP = 'gzip' +} diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/util/index.ts b/experimental/packages/exporter-trace-otlp-grpc/src/util/index.ts new file mode 100644 index 00000000000..2a7cd4fe8d6 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/util/index.ts @@ -0,0 +1,108 @@ +/* + * 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 * as grpc from '@grpc/grpc-js'; +import { diag } from '@opentelemetry/api'; +import { baggageUtils, ENVIRONMENT } from '@opentelemetry/core'; +import { ConnectionOptions } from '../internal/types'; +import { CompressionAlgorithm, OTLPGRPCTraceExporterConfig } from '../types'; +import { getCredentials } from './security'; + +const DEFAULT_COLLECTOR_URL = 'http://localhost:4317'; + +export function getConnectionOptions(config: OTLPGRPCTraceExporterConfig, env: Required): ConnectionOptions { + const metadata = getMetadata(config, env); + const url = getUrl(config, env); + const host = getHost(url); + const compression = configureCompression(config, env); + + if (url == null) { + return { + metadata, + credentials: config.credentials ?? grpc.credentials.createInsecure(), + host, + compression, + }; + } + + const credentials = config.credentials ?? getCredentials(url, env); + + return { + metadata, + credentials, + host, + compression, + }; +} + +export function configureCompression(config: OTLPGRPCTraceExporterConfig, env: Required): grpc.compressionAlgorithms { + switch (config.compression) { + case CompressionAlgorithm.GZIP: + return grpc.compressionAlgorithms.gzip; + case CompressionAlgorithm.NONE: + return grpc.compressionAlgorithms.identity; + } + + const definedCompression = env.OTEL_EXPORTER_OTLP_TRACES_COMPRESSION || env.OTEL_EXPORTER_OTLP_COMPRESSION; + return definedCompression === 'gzip' ? grpc.compressionAlgorithms.gzip : grpc.compressionAlgorithms.identity; +} + +function getMetadata(config: OTLPGRPCTraceExporterConfig, env: Required) { + const metadata = config.metadata ?? new grpc.Metadata(); + + const { OTEL_EXPORTER_OTLP_TRACES_HEADERS, OTEL_EXPORTER_OTLP_HEADERS } = env; + + + for (const h of [OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_TRACES_HEADERS]) { + if (h) { + for (const [k, v] of Object.entries(baggageUtils.parseKeyPairsIntoRecord(h))) { + metadata.set(k, v); + } + } + } + + return metadata; +} + +function getUrl(config: OTLPGRPCTraceExporterConfig, env: Required): string { + if (typeof config.url === 'string') { + return config.url; + } + + return env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || + env.OTEL_EXPORTER_OTLP_ENDPOINT || + DEFAULT_COLLECTOR_URL; +} + +function getHost(url: string): string { + const hasProtocol = url.match(/^([\w]{1,8}):\/\//); + if (!hasProtocol) { + url = `https://${url}`; + } + const target = new URL(url); + if (target.pathname && target.pathname !== '/') { + diag.warn( + 'URL path should not be set when using grpc, the path part of the URL will be ignored.' + ); + } + if (target.protocol !== '' && !target.protocol?.match(/^(http)s?:$/)) { + diag.warn( + 'URL protocol should be http(s)://. Using http://.' + ); + } + return target.host; +} + diff --git a/experimental/packages/exporter-trace-otlp-grpc/src/util/security.ts b/experimental/packages/exporter-trace-otlp-grpc/src/util/security.ts new file mode 100644 index 00000000000..da469186630 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/util/security.ts @@ -0,0 +1,108 @@ +/* + * 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 * as grpc from '@grpc/grpc-js'; +import { diag } from '@opentelemetry/api'; +import { ENVIRONMENT } from '@opentelemetry/core'; +import path = require('path'); +import fs = require('fs'); + +export function getCredentials(endpoint: string, env: Required): grpc.ChannelCredentials { + if (endpoint.startsWith('http://')) { + return grpc.credentials.createInsecure(); + } + + if (endpoint.startsWith('https://')) { + return useSecureConnection(env); + } + + if (envInsecureOption(env)) { + return grpc.credentials.createInsecure(); + } + + return useSecureConnection(env); +} + + +function envInsecureOption(env: Required): boolean { + const definedInsecure = + env.OTEL_EXPORTER_OTLP_TRACES_INSECURE || + env.OTEL_EXPORTER_OTLP_INSECURE; + + if (definedInsecure) { + return definedInsecure.toLowerCase() === 'true'; + } else { + return false; + } +} + +function useSecureConnection(env: Required): grpc.ChannelCredentials { + const rootCert = retrieveRootCert(env); + const privateKey = retrievePrivateKey(env); + const certChain = retrieveCertChain(env); + + return grpc.credentials.createSsl(rootCert, privateKey, certChain); +} + +function retrieveRootCert(env: Required): Buffer | undefined { + const rootCertificate = + env.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE || + env.OTEL_EXPORTER_OTLP_CERTIFICATE; + + if (rootCertificate) { + try { + return fs.readFileSync(path.resolve(process.cwd(), rootCertificate)); + } catch { + diag.warn('Failed to read root certificate file'); + return undefined; + } + } else { + return undefined; + } +} + +function retrievePrivateKey(env: Required): Buffer | undefined { + const clientKey = + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY || + env.OTEL_EXPORTER_OTLP_CLIENT_KEY; + + if (clientKey) { + try { + return fs.readFileSync(path.resolve(process.cwd(), clientKey)); + } catch { + diag.warn('Failed to read client certificate private key file'); + return undefined; + } + } else { + return undefined; + } +} + +function retrieveCertChain(env: Required): Buffer | undefined { + const clientChain = + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE || + env.OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE; + + if (clientChain) { + try { + return fs.readFileSync(path.resolve(process.cwd(), clientChain)); + } catch { + diag.warn('Failed to read client certificate chain file'); + return undefined; + } + } else { + return undefined; + } +} diff --git a/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts b/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts index d1052e9edc6..c431112948e 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts @@ -35,9 +35,10 @@ import { mockedReadableSpan, } from './traceHelper'; import * as core from '@opentelemetry/core'; -import { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base'; -import { GrpcCompressionAlgorithm } from '@opentelemetry/otlp-grpc-exporter-base'; +import { CompressionAlgorithm } from '../src/types'; import { IExportTraceServiceRequest, IResourceSpans } from '@opentelemetry/otlp-transformer'; +import { getConnectionOptions } from '../src/util'; +import { ENVIRONMENT, getEnv } from '@opentelemetry/core'; const traceServiceProtoPath = 'opentelemetry/proto/collector/trace/v1/trace_service.proto'; @@ -54,14 +55,13 @@ const metadata = new grpc.Metadata(); metadata.set('k', 'v'); const testCollectorExporter = (params: TestParams) => - describe(`OTLPTraceExporter - node ${ - params.useTLS ? 'with' : 'without' + describe(`OTLPTraceExporter - node ${params.useTLS ? 'with' : 'without' } TLS, ${params.metadata ? 'with' : 'without'} metadata`, () => { let collectorExporter: OTLPTraceExporter; let server: grpc.Server; let exportedData: - | IResourceSpans - | undefined; + | IResourceSpans + | undefined; let reqMetadata: grpc.Metadata | undefined; before(done => { @@ -84,9 +84,9 @@ const testCollectorExporter = (params: TestParams) => .service, { Export: (data: { - request: IExportTraceServiceRequest; - metadata: grpc.Metadata; - }) => { + request: IExportTraceServiceRequest; + metadata: grpc.Metadata; + }) => { if (data.request.resourceSpans != null) { exportedData = data.request.resourceSpans[0]; } @@ -142,18 +142,6 @@ const testCollectorExporter = (params: TestParams) => }); describe('instance', () => { - it('should warn about headers when using grpc', () => { - // Need to stub/spy on the underlying logger as the 'diag' instance is global - const spyLoggerWarn = sinon.stub(diag, 'warn'); - collectorExporter = new OTLPTraceExporter({ - url: `http://${address}`, - headers: { - foo: 'bar', - }, - }); - const args = spyLoggerWarn.args[0]; - assert.strictEqual(args[0], 'Headers cannot be set when using grpc'); - }); it('should warn about path in url', () => { const spyLoggerWarn = sinon.stub(diag, 'warn'); collectorExporter = new OTLPTraceExporter({ @@ -228,6 +216,19 @@ const testCollectorExporter = (params: TestParams) => }, 300); }); }); + + it('should not export after shutdown', done => { + const exportSpy = sinon.spy(collectorExporter['_serviceClient'], 'export'); + + const spans = [Object.assign({}, mockedReadableSpan)]; + collectorExporter.shutdown(); + collectorExporter.export(spans, () => { + sinon.assert.notCalled(exportSpy); + done(); + }); + + }); + describe('export - with gzip compression', () => { beforeEach(() => { const credentials = params.useTLS @@ -278,86 +279,73 @@ const testCollectorExporter = (params: TestParams) => }); }); describe('Trace Exporter with compression', () => { - const envSource = process.env; it('should return gzip compression algorithm on exporter', () => { - const credentials = params.useTLS - ? grpc.credentials.createSsl( - fs.readFileSync('./test/certs/ca.crt'), - fs.readFileSync('./test/certs/client.key'), - fs.readFileSync('./test/certs/client.crt') - ) - : grpc.credentials.createInsecure(); - - envSource.OTEL_EXPORTER_OTLP_COMPRESSION = 'gzip'; - collectorExporter = new OTLPTraceExporter({ - url: 'https://' + address, - credentials, - metadata: params.metadata, - }); - assert.strictEqual(collectorExporter.compression, GrpcCompressionAlgorithm.GZIP); - delete envSource.OTEL_EXPORTER_OTLP_COMPRESSION; + const env = getEnv(); + env.OTEL_EXPORTER_OTLP_COMPRESSION = 'gzip'; + const { compression } = getConnectionOptions({}, env); + assert.strictEqual(compression, grpc.compressionAlgorithms.gzip); }); }); }); describe('OTLPTraceExporter - node (getDefaultUrl)', () => { - it('should default to localhost', done => { - const collectorExporter = new OTLPTraceExporter({}); - setTimeout(() => { - assert.strictEqual(collectorExporter['url'], 'localhost:4317'); - done(); - }); + it('should default to insecure localhost', () => { + const { host, credentials } = getConnectionOptions({}, getEnv()); + assert.strictEqual(host, 'localhost:4317'); + assert.strictEqual(credentials._isSecure(), false); }); - it('should keep the URL if included', done => { - const url = 'http://foo.bar.com'; - const collectorExporter = new OTLPTraceExporter({ url }); - setTimeout(() => { - assert.strictEqual(collectorExporter['url'], 'foo.bar.com'); - done(); - }); + it('should keep the URL if included', () => { + const { host, credentials } = getConnectionOptions({ + url: 'http://foo.bar.com', + }, getEnv()); + assert.strictEqual(host, 'foo.bar.com'); + assert.strictEqual(credentials._isSecure(), false); }); }); describe('when configuring via environment', () => { - const envSource = process.env; + let env: Required; + + beforeEach(() => { + env = getEnv(); + }); + it('should use url defined in env', () => { - envSource.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://foo.bar'; - const collectorExporter = new OTLPTraceExporter(); - assert.strictEqual( - collectorExporter.url, - 'foo.bar' - ); - envSource.OTEL_EXPORTER_OTLP_ENDPOINT = ''; + env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://foo.bar'; + const { host, credentials } = getConnectionOptions({}, env); + assert.strictEqual(host, 'foo.bar'); + assert.strictEqual(credentials._isSecure(), false); }); + it('should override global exporter url with signal url defined in env', () => { - envSource.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://foo.bar'; - envSource.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://foo.traces'; - const collectorExporter = new OTLPTraceExporter(); - assert.strictEqual( - collectorExporter.url, - 'foo.traces' - ); - envSource.OTEL_EXPORTER_OTLP_ENDPOINT = ''; - envSource.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = ''; + env.OTEL_EXPORTER_OTLP_ENDPOINT = 'http://foo.bar'; + env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://foo.traces'; + + const { host, credentials } = getConnectionOptions({}, env); + + assert.strictEqual(host, 'foo.traces'); + assert.strictEqual(credentials._isSecure(), false); }); + it('should use headers defined via env', () => { - envSource.OTEL_EXPORTER_OTLP_HEADERS = 'foo=bar'; - const collectorExporter = new OTLPTraceExporter(); - assert.deepStrictEqual(collectorExporter.metadata?.get('foo'), ['bar']); - envSource.OTEL_EXPORTER_OTLP_HEADERS = ''; + env.OTEL_EXPORTER_OTLP_HEADERS = 'foo=bar'; + const { metadata } = getConnectionOptions({}, env); + assert.deepStrictEqual(metadata.get('foo'), ['bar']); }); + it('should override global headers config with signal headers defined via env', () => { - const metadata = new grpc.Metadata(); - metadata.set('foo', 'bar'); - metadata.set('goo', 'lol'); - envSource.OTEL_EXPORTER_OTLP_HEADERS = 'foo=jar,bar=foo'; - envSource.OTEL_EXPORTER_OTLP_TRACES_HEADERS = 'foo=boo'; - const collectorExporter = new OTLPTraceExporter({ metadata }); - assert.deepStrictEqual(collectorExporter.metadata?.get('foo'), ['boo']); - assert.deepStrictEqual(collectorExporter.metadata?.get('bar'), ['foo']); - assert.deepStrictEqual(collectorExporter.metadata?.get('goo'), ['lol']); - envSource.OTEL_EXPORTER_OTLP_TRACES_HEADERS = ''; - envSource.OTEL_EXPORTER_OTLP_HEADERS = ''; + const m = new grpc.Metadata(); + m.set('foo', 'bar'); + m.set('goo', 'lol'); + + env.OTEL_EXPORTER_OTLP_HEADERS = 'foo=jar,bar=foo'; + env.OTEL_EXPORTER_OTLP_TRACES_HEADERS = 'foo=boo'; + + const { metadata } = getConnectionOptions({ metadata: m }, env); + + assert.deepStrictEqual(metadata.get('foo'), ['boo']); + assert.deepStrictEqual(metadata.get('bar'), ['foo']); + assert.deepStrictEqual(metadata.get('goo'), ['lol']); }); }); diff --git a/experimental/packages/exporter-trace-otlp-grpc/test/internal/request-counter.test.ts b/experimental/packages/exporter-trace-otlp-grpc/test/internal/request-counter.test.ts new file mode 100644 index 00000000000..044b964a340 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/test/internal/request-counter.test.ts @@ -0,0 +1,58 @@ +/* + * 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 assert = require('assert'); +import { RequestCounter } from '../../src/internal/request-counter'; + +describe('RequestCounter', () => { + let counter: RequestCounter; + + beforeEach(() => { + counter = new RequestCounter(); + }); + + it('should start with 0 requests', () => { + assert.strictEqual(counter.requests, 0); + }); + + it('should count a request', () => { + counter.startRequest(); + assert.strictEqual(counter.requests, 1); + counter.endRequest(); + assert.strictEqual(counter.requests, 0); + }); + + it('should fire event on last request end', done => { + assert.strictEqual(counter.requests, 0); + counter.startRequest(); + assert.strictEqual(counter.requests, 1); + counter.onEndLastRequest(() => { + assert.strictEqual(counter.requests, 0); + done(); + }); + counter.startRequest(); + assert.strictEqual(counter.requests, 2); + counter.endRequest(); + assert.strictEqual(counter.requests, 1); + counter.endRequest(); + }); + + it('should throw if more requests are ended than started', () => { + counter.startRequest(); + counter.endRequest(); + assert.throws(counter.endRequest); + }); +}); diff --git a/experimental/packages/exporter-trace-otlp-grpc/test/util/security.test.ts b/experimental/packages/exporter-trace-otlp-grpc/test/util/security.test.ts new file mode 100644 index 00000000000..06d45e0c5e6 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/test/util/security.test.ts @@ -0,0 +1,106 @@ +/* + * 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 { diag } from '@opentelemetry/api'; +import { DEFAULT_ENVIRONMENT, ENVIRONMENT } from '@opentelemetry/core'; +import assert = require('assert'); +import Sinon = require('sinon'); +import * as grpc from '@grpc/grpc-js'; +import { getConnectionOptions } from '../../src/util'; + +describe('Security', () => { + describe('getConnectionOptions', () => { + let env: Required; + let diagSpy: Sinon.SinonSpy; + + beforeEach(() => { + env = Object.assign({}, DEFAULT_ENVIRONMENT); + diagSpy = Sinon.spy(diag, 'warn'); + }); + + afterEach(() => { + Sinon.restore(); + }); + + it('should use an insecure connection with http://', () => { + const { credentials } = getConnectionOptions({ url: 'http://foo.bar.baz' }, env); + assert.strictEqual(credentials._isSecure(), false); + }); + + it('should use a secure connection with https://', () => { + const { credentials } = getConnectionOptions({ url: 'https://foo.bar.baz' }, env); + assert.strictEqual(credentials._isSecure(), true); + }); + + it('should get certificate from traces env', () => { + env.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE = './test/certs/ca.crt'; + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE = './test/certs/client.crt'; + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY = './test/certs/client.key'; + const { credentials } = getConnectionOptions({ url: 'https://foo.bar.baz' }, env); + const opts = credentials._getConnectionOptions(); + assert.ok(opts); + assert.ok(opts.secureContext); + // warning is logged if credentials fail to load + Sinon.assert.notCalled(diagSpy); + }); + + it('should get certificate from global env', () => { + env.OTEL_EXPORTER_OTLP_CERTIFICATE = './test/certs/ca.crt'; + env.OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE = './test/certs/client.crt'; + env.OTEL_EXPORTER_OTLP_CLIENT_KEY = './test/certs/client.key'; + const { credentials } = getConnectionOptions({ url: 'https://foo.bar.baz' }, env); + const opts = credentials._getConnectionOptions(); + assert.ok(opts); + assert.ok(opts.secureContext); + // warning is logged if credentials fail to load + Sinon.assert.notCalled(diagSpy); + }); + + it('should prefer traces env over global', () => { + env.OTEL_EXPORTER_OTLP_CERTIFICATE = './test/certs/ca.crt.doesnotexist'; + env.OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE = './test/certs/client.crt.doesnotexist'; + env.OTEL_EXPORTER_OTLP_CLIENT_KEY = './test/certs/client.key.doesnotexist'; + env.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE = './test/certs/ca.crt'; + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE = './test/certs/client.crt'; + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY = './test/certs/client.key'; + const { credentials } = getConnectionOptions({ url: 'https://foo.bar.baz' }, env); + const opts = credentials._getConnectionOptions(); + assert.ok(opts); + assert.ok(opts.secureContext); + // warning is logged if credentials fail to load + Sinon.assert.notCalled(diagSpy); + }); + + it('should log a warning if any credentials cannot be loaded', () => { + env.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE = './test/certs/ca.crt.doesnotexist'; + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE = './test/certs/client.crt.doesnotexist'; + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY = './test/certs/client.key.doesnotexist'; + getConnectionOptions({ url: 'https://foo.bar.baz' }, env); + Sinon.assert.calledWithExactly(diagSpy.getCall(0), 'Failed to read root certificate file'); + Sinon.assert.calledWithExactly(diagSpy.getCall(1), 'Failed to read client certificate private key file'); + Sinon.assert.calledWithExactly(diagSpy.getCall(2), 'Failed to read client certificate chain file'); + }); + + it('should use provided credentials instead of env', () => { + env.OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE = './test/certs/ca.crt.doesnotexist'; + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE = './test/certs/client.crt.doesnotexist'; + env.OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY = './test/certs/client.key.doesnotexist'; + getConnectionOptions({ url: 'https://foo.bar.baz', credentials: grpc.credentials.createSsl() }, env); + // warn would be called if credentials tried to load because files don't exist + Sinon.assert.notCalled(diagSpy); + }); + }); +});