Repository: open-telemetry/opentelemetry-python

Issue:
Support env variable configuration for OTLP exporter
The spec describes environment variables that should be supported to configure the OTLP exporter, this feature request is to add support in the current implementation.

Spec PR:
https://github.com/open-telemetry/opentelemetry-specification/pull/699/files


Discussion:
@codeboten can i help fix this?
Looks like current OTLP exporter code doesn't cover all the cases covered in spec.

These seem like can be immediately supported:
* `OTEL_EXPORTER_OTLP_SPAN_ENDPOINT` `OTEL_EXPORTER_OTLP_METRIC_ENDPOINT` 
* `OTEL_EXPORTER_OTLP_SPAN_INSECURE` `OTEL_EXPORTER_OTLP_METRIC_INSECURE`
* `OTEL_EXPORTER_OTLP_SPAN_CERTIFICATE` `OTEL_EXPORTER_OTLP_METRIC_CERTIFICATE`

These require the cases to be implemented(for e.g. moving to trace/metric experimental API) before adding support for ENV based configuration:
* `OTEL_EXPORTER_OTLP_SPAN_PROTOCOL` `OTEL_EXPORTER_OTLP_METRIC_PROTOCOL`
* `OTEL_EXPORTER_OTLP_SPAN_HEADERS` `OTEL_EXPORTER_OTLP_METRIC_HEADERS`
* `OTEL_EXPORTER_OTLP_SPAN_COMPRESSION` `OTEL_EXPORTER_OTLP_METRIC_COMPRESSION`
* `OTEL_EXPORTER_OTLP_SPAN_TIMEOUT` `OTEL_EXPORTER_OTLP_METRIC_TIMEOUT`

Would you recommend we cover the top 3 cases and have a way to work with ENV variables, and for later cases the support can be added as they are implemented?
I think add env var based support to existing options makes sense. Adding new the missing options can be a different stream of work. 
I agree, supporting the ones that are already present seems like a good first step. Also worth implementing the case where a [general config](https://github.com/open-telemetry/opentelemetry-specification/blob/master/specification/protocol/exporter.md#configuration-options) option is set instead of `SPAN`/`METRIC` specific ones:
```
OTEL_EXPORTER_OTLP_ENDPOINT
OTEL_EXPORTER_OTLP_INSECURE
OTEL_EXPORTER_OTLP_CERTIFICATE
OTEL_EXPORTER_OTLP_HEADERS
```

Worth noting that the current OTLP exporter calls headers metadata: https://github.com/open-telemetry/opentelemetry-python/blob/master/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/exporter.py#L110

Resolving patch:
diff --git a/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/exporter.py b/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/exporter.py
--- a/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/exporter.py
+++ b/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/exporter.py
@@ -15,6 +15,7 @@
 """OTLP Exporter"""
 
 import logging
+import os
 from abc import ABC, abstractmethod
 from collections.abc import Mapping, Sequence
 from time import sleep
@@ -30,8 +31,10 @@
     StatusCode,
     insecure_channel,
     secure_channel,
+    ssl_channel_credentials,
 )
 
+from opentelemetry.configuration import Configuration
 from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue
 from opentelemetry.proto.resource.v1.resource_pb2 import Resource
 from opentelemetry.sdk.resources import Resource as SDKResource
@@ -113,6 +116,16 @@ def _get_resource_data(
     return resource_data
 
 
+def _load_credential_from_file(filepath) -> ChannelCredentials:
+    try:
+        with open(filepath, "rb") as f:
+            credential = f.read()
+            return ssl_channel_credentials(credential)
+    except FileNotFoundError:
+        logger.exception("Failed to read credential file")
+        return None
+
+
 # pylint: disable=no-member
 class OTLPExporterMixin(
     ABC, Generic[SDKDataT, ExportServiceRequestT, ExportResultT]
@@ -121,24 +134,47 @@ class OTLPExporterMixin(
 
     Args:
         endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
         credentials: ChannelCredentials object for server authentication
         metadata: Metadata to send when exporting
+        timeout: Backend request timeout in seconds
     """
 
     def __init__(
         self,
-        endpoint: str = "localhost:55680",
-        credentials: ChannelCredentials = None,
-        metadata: Optional[Tuple[Any]] = None,
+        endpoint: Optional[str] = None,
+        insecure: Optional[bool] = None,
+        credentials: Optional[ChannelCredentials] = None,
+        headers: Optional[str] = None,
+        timeout: Optional[int] = None,
     ):
         super().__init__()
 
-        self._metadata = metadata
+        endpoint = (
+            endpoint
+            or Configuration().EXPORTER_OTLP_ENDPOINT
+            or "localhost:55680"
+        )
+
+        if insecure is None:
+            insecure = Configuration().EXPORTER_OTLP_INSECURE
+        if insecure is None:
+            insecure = False
+
+        self._headers = headers or Configuration().EXPORTER_OTLP_HEADERS
+        self._timeout = (
+            timeout
+            or Configuration().EXPORTER_OTLP_TIMEOUT
+            or 10  # default: 10 seconds
+        )
         self._collector_span_kwargs = None
 
-        if credentials is None:
+        if insecure:
             self._client = self._stub(insecure_channel(endpoint))
         else:
+            credentials = credentials or _load_credential_from_file(
+                Configuration().EXPORTER_OTLP_CERTIFICATE
+            )
             self._client = self._stub(secure_channel(endpoint, credentials))
 
     @abstractmethod
@@ -164,7 +200,8 @@ def _export(self, data: TypingSequence[SDKDataT]) -> ExportResultT:
             try:
                 self._client.Export(
                     request=self._translate_data(data),
-                    metadata=self._metadata,
+                    metadata=self._headers,
+                    timeout=self._timeout,
                 )
 
                 return self._result.SUCCESS
diff --git a/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/metrics_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/metrics_exporter/__init__.py
--- a/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/metrics_exporter/__init__.py
+++ b/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/metrics_exporter/__init__.py
@@ -15,12 +15,16 @@
 """OTLP Metrics Exporter"""
 
 import logging
-from typing import List, Sequence, Type, TypeVar
+import os
+from typing import List, Optional, Sequence, Type, TypeVar, Union
 
-# pylint: disable=duplicate-code
+from grpc import ChannelCredentials
+
+from opentelemetry.configuration import Configuration
 from opentelemetry.exporter.otlp.exporter import (
     OTLPExporterMixin,
     _get_resource_data,
+    _load_credential_from_file,
 )
 from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import (
     ExportMetricsServiceRequest,
@@ -109,13 +113,47 @@ class OTLPMetricsExporter(
 
     Args:
         endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
         credentials: Credentials object for server authentication
         metadata: Metadata to send when exporting
+        timeout: Backend request timeout in seconds
     """
 
     _stub = MetricsServiceStub
     _result = MetricsExportResult
 
+    def __init__(
+        self,
+        endpoint: Optional[str] = None,
+        insecure: Optional[bool] = None,
+        credentials: Optional[ChannelCredentials] = None,
+        headers: Optional[str] = None,
+        timeout: Optional[int] = None,
+    ):
+        if insecure is None:
+            insecure = Configuration().EXPORTER_OTLP_METRIC_INSECURE
+
+        if (
+            not insecure
+            and Configuration().EXPORTER_OTLP_METRIC_CERTIFICATE is not None
+        ):
+            credentials = credentials or _load_credential_from_file(
+                Configuration().EXPORTER_OTLP_METRIC_CERTIFICATE
+            )
+
+        super().__init__(
+            **{
+                "endpoint": endpoint
+                or Configuration().EXPORTER_OTLP_METRIC_ENDPOINT,
+                "insecure": insecure,
+                "credentials": credentials,
+                "headers": headers
+                or Configuration().EXPORTER_OTLP_METRIC_HEADERS,
+                "timeout": timeout
+                or Configuration().EXPORTER_OTLP_METRIC_TIMEOUT,
+            }
+        )
+
     # pylint: disable=no-self-use
     def _translate_data(
         self, data: Sequence[MetricRecord]
diff --git a/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/trace_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/trace_exporter/__init__.py
--- a/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/trace_exporter/__init__.py
+++ b/exporter/opentelemetry-exporter-otlp/src/opentelemetry/exporter/otlp/trace_exporter/__init__.py
@@ -14,11 +14,16 @@
 """OTLP Span Exporter"""
 
 import logging
-from typing import Sequence
+import os
+from typing import Optional, Sequence
 
+from grpc import ChannelCredentials
+
+from opentelemetry.configuration import Configuration
 from opentelemetry.exporter.otlp.exporter import (
     OTLPExporterMixin,
     _get_resource_data,
+    _load_credential_from_file,
     _translate_key_values,
 )
 from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
@@ -50,13 +55,47 @@ class OTLPSpanExporter(
 
     Args:
         endpoint: OpenTelemetry Collector receiver endpoint
+        insecure: Connection type
         credentials: Credentials object for server authentication
         metadata: Metadata to send when exporting
+        timeout: Backend request timeout in seconds
     """
 
     _result = SpanExportResult
     _stub = TraceServiceStub
 
+    def __init__(
+        self,
+        endpoint: Optional[str] = None,
+        insecure: Optional[bool] = None,
+        credentials: Optional[ChannelCredentials] = None,
+        headers: Optional[str] = None,
+        timeout: Optional[int] = None,
+    ):
+        if insecure is None:
+            insecure = Configuration().EXPORTER_OTLP_SPAN_INSECURE
+
+        if (
+            not insecure
+            and Configuration().EXPORTER_OTLP_SPAN_CERTIFICATE is not None
+        ):
+            credentials = credentials or _load_credential_from_file(
+                Configuration().EXPORTER_OTLP_SPAN_CERTIFICATE
+            )
+
+        super().__init__(
+            **{
+                "endpoint": endpoint
+                or Configuration().EXPORTER_OTLP_SPAN_ENDPOINT,
+                "insecure": insecure,
+                "credentials": credentials,
+                "headers": headers
+                or Configuration().EXPORTER_OTLP_SPAN_HEADERS,
+                "timeout": timeout
+                or Configuration().EXPORTER_OTLP_SPAN_TIMEOUT,
+            }
+        )
+
     def _translate_name(self, sdk_span: SDKSpan) -> None:
         self._collector_span_kwargs["name"] = sdk_span.name
 


Test patch:
diff --git a/exporter/opentelemetry-exporter-otlp/tests/fixtures/test.cert b/exporter/opentelemetry-exporter-otlp/tests/fixtures/test.cert
new file mode 100644
diff --git a/exporter/opentelemetry-exporter-otlp/tests/test_otlp_metric_exporter.py b/exporter/opentelemetry-exporter-otlp/tests/test_otlp_metric_exporter.py
--- a/exporter/opentelemetry-exporter-otlp/tests/test_otlp_metric_exporter.py
+++ b/exporter/opentelemetry-exporter-otlp/tests/test_otlp_metric_exporter.py
@@ -16,6 +16,9 @@
 from unittest import TestCase
 from unittest.mock import patch
 
+from grpc import ChannelCredentials
+
+from opentelemetry.configuration import Configuration
 from opentelemetry.exporter.otlp.metrics_exporter import OTLPMetricsExporter
 from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import (
     ExportMetricsServiceRequest,
@@ -44,8 +47,9 @@
 
 class TestOTLPMetricExporter(TestCase):
     def setUp(self):
-        self.exporter = OTLPMetricsExporter()
+        self.exporter = OTLPMetricsExporter(insecure=True)
         resource = SDKResource(OrderedDict([("a", 1), ("b", False)]))
+
         self.counter_metric_record = MetricRecord(
             Counter(
                 "a",
@@ -60,6 +64,33 @@ def setUp(self):
             resource,
         )
 
+        Configuration._reset()  # pylint: disable=protected-access
+
+    def tearDown(self):
+        Configuration._reset()  # pylint: disable=protected-access
+
+    @patch.dict(
+        "os.environ",
+        {
+            "OTEL_EXPORTER_OTLP_METRIC_ENDPOINT": "collector:55680",
+            "OTEL_EXPORTER_OTLP_METRIC_CERTIFICATE": "fixtures/test.cert",
+            "OTEL_EXPORTER_OTLP_METRIC_HEADERS": "key1:value1;key2:value2",
+            "OTEL_EXPORTER_OTLP_METRIC_TIMEOUT": "10",
+        },
+    )
+    @patch("opentelemetry.exporter.otlp.exporter.OTLPExporterMixin.__init__")
+    def test_env_variables(self, mock_exporter_mixin):
+        OTLPMetricsExporter()
+
+        self.assertTrue(len(mock_exporter_mixin.call_args_list) == 1)
+        _, kwargs = mock_exporter_mixin.call_args_list[0]
+
+        self.assertEqual(kwargs["endpoint"], "collector:55680")
+        self.assertEqual(kwargs["headers"], "key1:value1;key2:value2")
+        self.assertEqual(kwargs["timeout"], 10)
+        self.assertIsNotNone(kwargs["credentials"])
+        self.assertIsInstance(kwargs["credentials"], ChannelCredentials)
+
     @patch("opentelemetry.sdk.metrics.export.aggregate.time_ns")
     def test_translate_metrics(self, mock_time_ns):
         # pylint: disable=no-member
diff --git a/exporter/opentelemetry-exporter-otlp/tests/test_otlp_trace_exporter.py b/exporter/opentelemetry-exporter-otlp/tests/test_otlp_trace_exporter.py
--- a/exporter/opentelemetry-exporter-otlp/tests/test_otlp_trace_exporter.py
+++ b/exporter/opentelemetry-exporter-otlp/tests/test_otlp_trace_exporter.py
@@ -19,8 +19,9 @@
 
 from google.protobuf.duration_pb2 import Duration
 from google.rpc.error_details_pb2 import RetryInfo
-from grpc import StatusCode, server
+from grpc import ChannelCredentials, StatusCode, server
 
+from opentelemetry.configuration import Configuration
 from opentelemetry.exporter.otlp.trace_exporter import OTLPSpanExporter
 from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
     ExportTraceServiceRequest,
@@ -102,7 +103,7 @@ def Export(self, request, context):
 class TestOTLPSpanExporter(TestCase):
     def setUp(self):
         tracer_provider = TracerProvider()
-        self.exporter = OTLPSpanExporter()
+        self.exporter = OTLPSpanExporter(insecure=True)
         tracer_provider.add_span_processor(
             SimpleExportSpanProcessor(self.exporter)
         )
@@ -154,8 +155,33 @@ def setUp(self):
         self.span.start()
         self.span.end()
 
+        Configuration._reset()  # pylint: disable=protected-access
+
     def tearDown(self):
         self.server.stop(None)
+        Configuration._reset()  # pylint: disable=protected-access
+
+    @patch.dict(
+        "os.environ",
+        {
+            "OTEL_EXPORTER_OTLP_SPAN_ENDPOINT": "collector:55680",
+            "OTEL_EXPORTER_OTLP_SPAN_CERTIFICATE": "fixtures/test.cert",
+            "OTEL_EXPORTER_OTLP_SPAN_HEADERS": "key1:value1;key2:value2",
+            "OTEL_EXPORTER_OTLP_SPAN_TIMEOUT": "10",
+        },
+    )
+    @patch("opentelemetry.exporter.otlp.exporter.OTLPExporterMixin.__init__")
+    def test_env_variables(self, mock_exporter_mixin):
+        OTLPSpanExporter()
+
+        self.assertTrue(len(mock_exporter_mixin.call_args_list) == 1)
+        _, kwargs = mock_exporter_mixin.call_args_list[0]
+
+        self.assertEqual(kwargs["endpoint"], "collector:55680")
+        self.assertEqual(kwargs["headers"], "key1:value1;key2:value2")
+        self.assertEqual(kwargs["timeout"], 10)
+        self.assertIsNotNone(kwargs["credentials"])
+        self.assertIsInstance(kwargs["credentials"], ChannelCredentials)
 
     @patch("opentelemetry.exporter.otlp.exporter.expo")
     @patch("opentelemetry.exporter.otlp.exporter.sleep")