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/packages/exporter-trace-otlp-grpc/package.json b/experimental/packages/exporter-trace-otlp-grpc/package.json index 8412dd8eda5..8dcbd36451b 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/package.json +++ b/experimental/packages/exporter-trace-otlp-grpc/package.json @@ -49,7 +49,6 @@ "devDependencies": { "@babel/core": "7.16.0", "@opentelemetry/api": "^1.0.0", - "@opentelemetry/otlp-exporter-base": "0.30.0", "@types/mocha": "8.2.3", "@types/node": "14.17.33", "@types/sinon": "10.0.6", @@ -70,9 +69,7 @@ "@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..fd9fdb0586a 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts +++ b/experimental/packages/exporter-trace-otlp-grpc/src/OTLPTraceExporter.ts @@ -14,58 +14,82 @@ * 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 type { OTLPGRPCTraceExporterConfig, TraceServiceClient } 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; + public url: string; + public compression: grpc.compressionAlgorithms; - 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.url = host; + this.compression = compression + this._metadata = metadata; - 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 + const packageObject: any = grpc.loadPackageDefinition(packageDefinition); + const channelOptions: grpc.ChannelOptions = { + 'grpc.default_compression_algorithm': this.compression, + }; + console.log(host, credentials, channelOptions) + 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 { + // TODO + const deadline = Date.now() + 1000; - getServiceProtoPath(): string { - return 'opentelemetry/proto/collector/trace/v1/trace_service.proto'; + const req = createExportTraceServiceRequest(spans); + console.log(req) + this._serviceClient.export( + req, + this._metadata, + { deadline }, + (...args: unknown[]) => { + console.log(args) + if (args[0]) { + resultCallback({ + code: ExportResultCode.FAILED, + error: args[0] as any, + }); + } else { + resultCallback({ + code: ExportResultCode.SUCCESS, + }); + } + }, + ); } - getUrlFromConfig(config: OTLPGRPCExporterConfigNode): string { - if (typeof config.url === 'string') { - return config.url; - } - - return getEnv().OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || - getEnv().OTEL_EXPORTER_OTLP_ENDPOINT || - DEFAULT_COLLECTOR_URL; + shutdown(): Promise { + throw new Error('Method not implemented.'); } } 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..8e331849b55 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/types.ts @@ -0,0 +1,52 @@ +/* + * 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 ConnectionOptions = { + host: string; + metadata: grpc.Metadata; + credentials: grpc.ChannelCredentials; + compression: grpc.compressionAlgorithms; +} + +export type OTLPGRPCTraceExporterConfig = { + credentials?: grpc.ChannelCredentials; + metadata?: grpc.Metadata; + compression?: CompressionAlgorithm; + + headers?: Partial>; + hostname?: string; + url?: string; + concurrencyLimit?: number; + /** 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' +} + +export interface TraceServiceClient extends grpc.Client { + export: ( + request: any, + metadata: grpc.Metadata, + options: grpc.CallOptions, + callback: Function + ) => {}; +} 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..3c91f97a9ae --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/util/index.ts @@ -0,0 +1,123 @@ +/* + * 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 { CompressionAlgorithm, ConnectionOptions, 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 metadataMap = metadata.getMap(); + + const envHeaders = baggageUtils.parseKeyPairsIntoRecord(env.OTEL_EXPORTER_OTLP_HEADERS); + const envTraceHeaders = baggageUtils.parseKeyPairsIntoRecord(env.OTEL_EXPORTER_OTLP_TRACES_HEADERS); + const headers = config.headers ?? {}; + + console.log('env', env.OTEL_EXPORTER_OTLP_TRACES_HEADERS) + console.log('config', config.headers) + console.log('env headers', envHeaders) + + for (const [k, v] of Object.entries(headers)) { + if (metadataMap[k] == null) { + metadata.set(k, String(v)); + } + } + + for (const [k, v] of Object.entries(envTraceHeaders)) { + if (metadataMap[k] == null) { + metadata.set(k, v); + } + } + + for (const [k, v] of Object.entries(envHeaders)) { + if (metadataMap[k] == null) { + 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..98b7bb6c806 --- /dev/null +++ b/experimental/packages/exporter-trace-otlp-grpc/src/util/security.ts @@ -0,0 +1,93 @@ +import * as grpc from "@grpc/grpc-js"; +import { diag } from "@opentelemetry/api"; +import { ENVIRONMENT, getEnv } 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(); + } + + if (envInsecureOption(env)) { + return grpc.credentials.createInsecure(); + } + + return useSecureConnection(); +} + + +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(): grpc.ChannelCredentials { + const rootCertPath = retrieveRootCert(); + const privateKeyPath = retrievePrivateKey(); + const certChainPath = retrieveCertChain(); + + return grpc.credentials.createSsl(rootCertPath, privateKeyPath, certChainPath); +} + +function retrieveRootCert(): Buffer | undefined { + const rootCertificate = + getEnv().OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE || + getEnv().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(): Buffer | undefined { + const clientKey = + getEnv().OTEL_EXPORTER_OTLP_TRACES_CLIENT_KEY || + getEnv().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(): Buffer | undefined { + const clientChain = + getEnv().OTEL_EXPORTER_OTLP_TRACES_CLIENT_CERTIFICATE || + getEnv().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..986466e52ec 100644 --- a/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts +++ b/experimental/packages/exporter-trace-otlp-grpc/test/OTLPTraceExporter.test.ts @@ -343,7 +343,7 @@ describe('when configuring via environment', () => { 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']); + assert.deepStrictEqual(collectorExporter["_metadata"]?.get('foo'), ['bar']); envSource.OTEL_EXPORTER_OTLP_HEADERS = ''; }); it('should override global headers config with signal headers defined via env', () => { @@ -353,9 +353,9 @@ describe('when configuring via environment', () => { 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']); + 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 = ''; });