Repository: open-telemetry/opentelemetry-python

Issue:
Implement a mechanism to configure exporters with autoinstrumentation command
The goal of the `opentelemetry-auto-instrumentation` command is to allow the user to instrument an application without having to write code. That command is able to instrument the different supported libraries by invoking the instrumentor entrypoint on them, but the user still has to modify the program code to setup the span processor/exporters.

A mechanism that allows the user to configure opentelemetry (processors/exporters, propagation formats, specific library settings) without touching the application code is needed. 

It's needed to understand how this could couple with the current configuration mechanism (opentelemetry-api/src/opentelemetry/configuration) and the format to be used https://github.com/open-telemetry/opentelemetry-python/issues/564, also how flexible it should be, for instance, will the user be able to pass advance features like https://github.com/open-telemetry/opentelemetry-python/pull/656?

Another consideration is if this command is only intended to be used with the official SDK or it should be generic.



Discussion:
I think that work could be done in an "instrumentor" for a vendor exporter. E.g., the `_instrument` method would add the vendor exporter to a span processor as well as set up the vendor propagator. This would help in the case of what I've been doing for the Datadog exporter.

Though this would broaden term "instrumentor" beyond enabling tracing for third-party library.
As a first step, I think auto-instrumentation should automatically enable the OTLP exporter by default. All exporters should be configurable with environment variables so the CLI interface doesn't get too polluted. It could look something like:

```
export ZIPKIN_EXPORTER_HOST=localhost
export ZIPKIN_EXPORTER_PORT=9412
export OPENTELEMETRY_EXPORTER=opentelemetry.ext.zipkin.ZipkinSpanExporter
opentelemetry-instrument flask run 
```

or

```
export ZIPKIN_EXPORTER_HOST=localhost
export ZIPKIN_EXPORTER_PORT=9412
opentelemetry-instrument --exporter=opentelemetry.ext.zipkin.ZipkinSpanExporter flask run 
```
In any case, I think as a first step, we should enable OTLP by default when running `opentelemetry-instrument`. One open question with this is how do we detect that the user has enabled a different exporter in code and then disable the default exporter. We can detect the existence of another exporter but how do we detect the intent? The simple answer is to always disable auto-enabled exporter if any other exporters are configured by the user but would like to hear more thoughts on this. 
Can someone assign this to me please?
Done!
@owais If the OTLP exporter is configured by default, would you expect the `opentelemetry-ext-otlp` package to become a dependency of the `opentelemetry-instrumentation` package? Or would it still be installed separately and report an error if it wasn't present? An alternative could be to move the otlp exporter into the SDK, but then that makes grpc a requirement of the SDK.

I wonder if there's a way we could use entrypoints to make the configuration easier for our users here.
As an end user, I'd expect it to work out of box when running `opentelemetry-instrument <app>` command. I think perhaps the bootstrap script can help? May be we can add an `--exporter` flag to bootstrap that installs the specified exporter but defaults to otlp. Then the instrument command can have some mechanism to detect the installed and preferred exporter. Thoughts?
I'd prefer to not have any default behavior regarding things that are supposed to be configurable (exporters). What about a configuration file that can be passed into the `opentelemetry-instrument`? Using env variables doesn't seem as scalable, especially with the ever growing list of configurable "things" that users can do.
I like the idea of a [configuration file](https://github.com/open-telemetry/opentelemetry-python/issues/555) , makes it easy to check in and re-use as well. Would be nice if config validation take care of reporting any things that are configured but unavailable as well.

Re. environment variables it's worth noting this issue: https://github.com/open-telemetry/opentelemetry-specification/issues/572 
This doc was put together by @mauriciovasquezbernal around what a configuration file could look like: https://docs.google.com/document/d/10GW_yfi0yHozZIpUdi3t7Vg3XLftID1brnnzPwWK-Is/edit
I think there are two issues to discuss here. 

1. Default exporter (and other components) when using auto-instrumentation.
2. Way to override the defaults from outside the code with configuration. 

I don't think we should mix the two. IMO *OpenTelemetry* Python library should definitely default to the *OpenTelemetry* exporter, at least when using the auto-instrumentation mechanism to start a service. As an end user, I think having sane (and expected) defaults in invaluable. We should reduce as much friction as we can when people are trying to get started. 

I think an experience like the following is worth having:

```
pip install opentelemtry-instrumentation
opentelemtry-instrumentation -a=install
opentelemetry-bootstrap python main.py
```

This should be all that is required to instrument a Python service and start exporting telemetry to OpenTelemetry collector. In reality users would need to install SDK/API/Exporter Python packages as well but that is a different problem and can be solved either by having a meta-package like opentelemetry-all/opentelemetry-defaults or opentelemetry-bootstrap command can be extended to also install any SDK, Exporter or other component packages. 

To clarify, I'm not suggesting opentelemtry-python to always default to OTLP exporter. Only suggesting that `opentelemetry-instrumentation` CLI command has sane defaults and doesn't force users to go and configure things (but certainly allows them to). 

Resolving patch:
diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/__init__.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/__init__.py
--- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/__init__.py
+++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/__init__.py
@@ -14,16 +14,67 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import argparse
 from logging import getLogger
 from os import environ, execl, getcwd
 from os.path import abspath, dirname, pathsep
 from shutil import which
-from sys import argv
 
 logger = getLogger(__file__)
 
 
+def parse_args():
+    parser = argparse.ArgumentParser(
+        description="""
+        opentelemetry-instrument automatically instruments a Python
+        program and it's dependencies and then runs the program.
+        """
+    )
+
+    parser.add_argument(
+        "-e",
+        "--exporter",
+        required=False,
+        help="""
+        Uses the specified exporter to export spans or metrics.
+        Accepts multiple exporters as comma separated values.
+
+        Examples:
+
+            -e=otlp
+            -e=otlp_span,prometheus
+            -e=jaeger,otlp_metric
+        """,
+    )
+
+    parser.add_argument(
+        "-s",
+        "--service-name",
+        required=False,
+        help="""
+        The service name that should be passed to a trace exporter.
+        """,
+    )
+
+    parser.add_argument("command", help="Your Python application.")
+    parser.add_argument(
+        "command_args",
+        help="Arguments for your application.",
+        nargs=argparse.REMAINDER,
+    )
+    return parser.parse_args()
+
+
+def load_config_from_cli_args(args):
+    if args.exporter:
+        environ["OTEL_EXPORTER"] = args.exporter
+    if args.service_name:
+        environ["OTEL_SERVICE_NAME"] = args.service_name
+
+
 def run() -> None:
+    args = parse_args()
+    load_config_from_cli_args(args)
 
     python_path = environ.get("PYTHONPATH")
 
@@ -49,6 +100,5 @@ def run() -> None:
 
     environ["PYTHONPATH"] = pathsep.join(python_path)
 
-    executable = which(argv[1])
-
-    execl(executable, executable, *argv[2:])
+    executable = which(args.command)
+    execl(executable, executable, *args.command_args)
diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/components.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/components.py
new file mode 100644
--- /dev/null
+++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/components.py
@@ -0,0 +1,121 @@
+# 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
+#
+#     http://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.
+
+from logging import getLogger
+from typing import Sequence, Tuple
+
+from pkg_resources import iter_entry_points
+
+from opentelemetry import trace
+from opentelemetry.configuration import Configuration
+from opentelemetry.sdk.metrics.export import MetricsExporter
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import (
+    BatchExportSpanProcessor,
+    SpanExporter,
+)
+
+logger = getLogger(__file__)
+
+EXPORTER_OTLP = "otlp"
+EXPORTER_OTLP_SPAN = "otlp_span"
+EXPORTER_OTLP_METRIC = "otlp_metric"
+_DEFAULT_EXPORTER = EXPORTER_OTLP
+
+
+def get_service_name() -> str:
+    return Configuration().SERVICE_NAME or ""
+
+
+def get_exporter_names() -> Sequence[str]:
+    exporter = Configuration().EXPORTER or _DEFAULT_EXPORTER
+    if exporter.lower().strip() == "none":
+        return []
+
+    names = []
+    for exp in exporter.split(","):
+        name = exp.strip()
+        if name == EXPORTER_OTLP:
+            names.append(EXPORTER_OTLP_SPAN)
+            names.append(EXPORTER_OTLP_METRIC)
+        else:
+            names.append(name)
+    return names
+
+
+def init_tracing(exporters: Sequence[SpanExporter]):
+    service_name = get_service_name()
+    provider = TracerProvider(
+        resource=Resource.create({"service.name": service_name}),
+    )
+    trace.set_tracer_provider(provider)
+
+    for exporter_name, exporter_class in exporters.items():
+        exporter_args = {}
+        if exporter_name not in [
+            EXPORTER_OTLP,
+            EXPORTER_OTLP_SPAN,
+        ]:
+            exporter_args["service_name"] = service_name
+
+        provider.add_span_processor(
+            BatchExportSpanProcessor(exporter_class(**exporter_args))
+        )
+
+
+def init_metrics(exporters: Sequence[MetricsExporter]):
+    if exporters:
+        logger.warning("automatic metric initialization is not supported yet.")
+
+
+def import_exporters(
+    exporter_names: Sequence[str],
+) -> Tuple[Sequence[SpanExporter], Sequence[MetricsExporter]]:
+    trace_exporters, metric_exporters = {}, {}
+
+    exporters = {
+        ep.name: ep for ep in iter_entry_points("opentelemetry_exporter")
+    }
+
+    for exporter_name in exporter_names:
+        entry_point = exporters.get(exporter_name, None)
+        if not entry_point:
+            raise RuntimeError(
+                "Requested exporter not found: {0}".format(exporter_name)
+            )
+
+        exporter_impl = entry_point.load()
+        if issubclass(exporter_impl, SpanExporter):
+            trace_exporters[exporter_name] = exporter_impl
+        elif issubclass(exporter_impl, MetricsExporter):
+            metric_exporters[exporter_name] = exporter_impl
+        else:
+            raise RuntimeError(
+                "{0} is neither a trace exporter nor a metric exporter".format(
+                    exporter_name
+                )
+            )
+    return trace_exporters, metric_exporters
+
+
+def initialize_components():
+    exporter_names = get_exporter_names()
+    trace_exporters, metric_exporters = import_exporters(exporter_names)
+    init_tracing(trace_exporters)
+
+    # We don't support automatic initialization for metric yet but have added
+    # some boilerplate in order to make sure current implementation does not
+    # lock us out of supporting metrics later without major surgery.
+    init_metrics(metric_exporters)
diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py
--- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py
+++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/sitecustomize.py
@@ -12,17 +12,48 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import os
+import sys
 from logging import getLogger
 
 from pkg_resources import iter_entry_points
 
+from opentelemetry.instrumentation.auto_instrumentation.components import (
+    initialize_components,
+)
+
 logger = getLogger(__file__)
 
 
-for entry_point in iter_entry_points("opentelemetry_instrumentor"):
-    try:
-        entry_point.load()().instrument()  # type: ignore
-        logger.debug("Instrumented %s", entry_point.name)
+def auto_instrument():
+    for entry_point in iter_entry_points("opentelemetry_instrumentor"):
+        try:
+            entry_point.load()().instrument()  # type: ignore
+            logger.debug("Instrumented %s", entry_point.name)
+        except Exception as exc:  # pylint: disable=broad-except
+            logger.exception("Instrumenting of %s failed", entry_point.name)
+            raise exc
+
 
+def initialize():
+    try:
+        initialize_components()
+        auto_instrument()
     except Exception:  # pylint: disable=broad-except
-        logger.exception("Instrumenting of %s failed", entry_point.name)
+        logger.exception("Failed to auto initialize opentelemetry")
+
+
+if (
+    hasattr(sys, "argv")
+    and sys.argv[0].split(os.path.sep)[-1] == "celery"
+    and "worker" in sys.argv[1:]
+):
+    from celery.signals import worker_process_init  # pylint:disable=E0401
+
+    @worker_process_init.connect(weak=False)
+    def init_celery(*args, **kwargs):
+        initialize()
+
+
+else:
+    initialize()


Test patch:
diff --git a/opentelemetry-instrumentation/tests/test_auto_tracing.py b/opentelemetry-instrumentation/tests/test_auto_tracing.py
new file mode 100644
--- /dev/null
+++ b/opentelemetry-instrumentation/tests/test_auto_tracing.py
@@ -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
+#
+#     http://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.
+# type: ignore
+
+from os import environ
+from unittest import TestCase
+from unittest.mock import patch
+
+from opentelemetry.configuration import Configuration
+from opentelemetry.instrumentation.auto_instrumentation import components
+from opentelemetry.sdk.resources import Resource
+
+
+class Provider:
+    def __init__(self, resource=None):
+        self.processor = None
+        self.resource = resource
+
+    def add_span_processor(self, processor):
+        self.processor = processor
+
+
+class Processor:
+    def __init__(self, exporter):
+        self.exporter = exporter
+
+
+class Exporter:
+    def __init__(self, service_name):
+        self.service_name = service_name
+
+    def shutdown(self):
+        pass
+
+
+class OTLPExporter:
+    pass
+
+
+class TestTraceInit(TestCase):
+    def setUp(self):
+        super()
+        self.get_provider_patcher = patch(
+            "opentelemetry.instrumentation.auto_instrumentation.components.TracerProvider",
+            Provider,
+        )
+        self.get_processor_patcher = patch(
+            "opentelemetry.instrumentation.auto_instrumentation.components.BatchExportSpanProcessor",
+            Processor,
+        )
+        self.set_provider_patcher = patch(
+            "opentelemetry.trace.set_tracer_provider"
+        )
+
+        self.get_provider_mock = self.get_provider_patcher.start()
+        self.get_processor_mock = self.get_processor_patcher.start()
+        self.set_provider_mock = self.set_provider_patcher.start()
+
+    def tearDown(self):
+        super()
+        self.get_provider_patcher.stop()
+        self.get_processor_patcher.stop()
+        self.set_provider_patcher.stop()
+
+    # pylint: disable=protected-access
+    def test_trace_init_default(self):
+        environ["OTEL_SERVICE_NAME"] = "my-test-service"
+        Configuration._reset()
+        components.init_tracing({"zipkin": Exporter})
+
+        self.assertEqual(self.set_provider_mock.call_count, 1)
+        provider = self.set_provider_mock.call_args[0][0]
+        self.assertIsInstance(provider, Provider)
+        self.assertIsInstance(provider.processor, Processor)
+        self.assertIsInstance(provider.processor.exporter, Exporter)
+        self.assertEqual(
+            provider.processor.exporter.service_name, "my-test-service"
+        )
+
+    def test_trace_init_otlp(self):
+        environ["OTEL_SERVICE_NAME"] = "my-otlp-test-service"
+        Configuration._reset()
+        components.init_tracing({"otlp": OTLPExporter})
+
+        self.assertEqual(self.set_provider_mock.call_count, 1)
+        provider = self.set_provider_mock.call_args[0][0]
+        self.assertIsInstance(provider, Provider)
+        self.assertIsInstance(provider.processor, Processor)
+        self.assertIsInstance(provider.processor.exporter, OTLPExporter)
+        self.assertIsInstance(provider.resource, Resource)
+        self.assertEqual(
+            provider.resource.attributes.get("service.name"),
+            "my-otlp-test-service",
+        )
+        del environ["OTEL_SERVICE_NAME"]
diff --git a/opentelemetry-instrumentation/tests/test_run.py b/opentelemetry-instrumentation/tests/test_run.py
--- a/opentelemetry-instrumentation/tests/test_run.py
+++ b/opentelemetry-instrumentation/tests/test_run.py
@@ -26,9 +26,6 @@ class TestRun(TestCase):
 
     @classmethod
     def setUpClass(cls):
-        cls.argv_patcher = patch(
-            "opentelemetry.instrumentation.auto_instrumentation.argv"
-        )
         cls.execl_patcher = patch(
             "opentelemetry.instrumentation.auto_instrumentation.execl"
         )
@@ -36,16 +33,15 @@ def setUpClass(cls):
             "opentelemetry.instrumentation.auto_instrumentation.which"
         )
 
-        cls.argv_patcher.start()
         cls.execl_patcher.start()
         cls.which_patcher.start()
 
     @classmethod
     def tearDownClass(cls):
-        cls.argv_patcher.stop()
         cls.execl_patcher.stop()
         cls.which_patcher.stop()
 
+    @patch("sys.argv", ["instrument", ""])
     @patch.dict("os.environ", {"PYTHONPATH": ""})
     def test_empty(self):
         auto_instrumentation.run()
@@ -54,6 +50,7 @@ def test_empty(self):
             pathsep.join([self.auto_instrumentation_path, getcwd()]),
         )
 
+    @patch("sys.argv", ["instrument", ""])
     @patch.dict("os.environ", {"PYTHONPATH": "abc"})
     def test_non_empty(self):
         auto_instrumentation.run()
@@ -62,6 +59,7 @@ def test_non_empty(self):
             pathsep.join([self.auto_instrumentation_path, getcwd(), "abc"]),
         )
 
+    @patch("sys.argv", ["instrument", ""])
     @patch.dict(
         "os.environ",
         {"PYTHONPATH": pathsep.join(["abc", auto_instrumentation_path])},
@@ -73,6 +71,7 @@ def test_after_path(self):
             pathsep.join([self.auto_instrumentation_path, getcwd(), "abc"]),
         )
 
+    @patch("sys.argv", ["instrument", ""])
     @patch.dict(
         "os.environ",
         {
@@ -90,10 +89,7 @@ def test_single_path(self):
 
 
 class TestExecl(TestCase):
-    @patch(
-        "opentelemetry.instrumentation.auto_instrumentation.argv",
-        new=[1, 2, 3],
-    )
+    @patch("sys.argv", ["1", "2", "3"])
     @patch("opentelemetry.instrumentation.auto_instrumentation.which")
     @patch("opentelemetry.instrumentation.auto_instrumentation.execl")
     def test_execl(
@@ -103,4 +99,26 @@ def test_execl(
 
         auto_instrumentation.run()
 
-        mock_execl.assert_called_with("python", "python", 3)
+        mock_execl.assert_called_with("python", "python", "3")
+
+
+class TestArgs(TestCase):
+    @patch("opentelemetry.instrumentation.auto_instrumentation.execl")
+    def test_exporter(self, _):  # pylint: disable=no-self-use
+        with patch("sys.argv", ["instrument", "2"]):
+            auto_instrumentation.run()
+            self.assertIsNone(environ.get("OTEL_EXPORTER"))
+
+        with patch("sys.argv", ["instrument", "-e", "zipkin", "1", "2"]):
+            auto_instrumentation.run()
+            self.assertEqual(environ.get("OTEL_EXPORTER"), "zipkin")
+
+    @patch("opentelemetry.instrumentation.auto_instrumentation.execl")
+    def test_service_name(self, _):  # pylint: disable=no-self-use
+        with patch("sys.argv", ["instrument", "2"]):
+            auto_instrumentation.run()
+            self.assertIsNone(environ.get("OTEL_SERVICE_NAME"))
+
+        with patch("sys.argv", ["instrument", "-s", "my-service", "1", "2"]):
+            auto_instrumentation.run()
+            self.assertEqual(environ.get("OTEL_SERVICE_NAME"), "my-service")