diff --git a/examples/handlers/opentelemetry_loguru/README.md b/examples/handlers/opentelemetry_loguru/README.md new file mode 100644 index 0000000000..ba6340b6a9 --- /dev/null +++ b/examples/handlers/opentelemetry_loguru/README.md @@ -0,0 +1,138 @@ +# OpenTelemetry Python `loguru` Handler Example with Docker +This is a demo for the custom loguru handler implemented for OpenTelemetry. Overall, this example runs a basic Flask application with Docker to demonstrate an example application that uses OpenTelemetry logging with Python's logging library loguru. This example is scalable to other software systems that require the use of the loguru library for logging. + +Note: This example is adapted from OpenTelemetry's [Getting Started Tutorial for Python](https://opentelemetry.io/docs/languages/python/getting-started/) guide and OpenTelemetry's [example for logs](https://github.com/open-telemetry/opentelemetry-python/blob/main/docs/examples/logs/README.rst) code. + +## Prerequisites +Python 3 + +## Installation +Prior to building the example application, set up the directory and virtual environment: +``` +mkdir otel-loguru-example +cd otel-loguru-example +python3 -m venv venv +source ./venv/bin/activate +``` + +After activating the virtual environment `venv`, install flask and loguru. +``` +pip install flask +pip install loguru +pip install opentelemetry-exporter-otlp +``` + +### Create and Launch HTTP Server +Now that the environment is set up, create an `app.py` flask application. This is a basic example that uses the loguru Python logging library for OpenTelemetry logging instead of the standard Python logging library. + +Notice the importance of the following imports for using the loguru handler: `import loguru` and `from handlers.opentelemetry_loguru.src.exporter import LoguruHandler`. + +``` +from random import randint +from flask import Flask, request +from loguru import logger as loguru_logger +import sys +sys.path.insert(0, '../../..') +from handlers.opentelemetry_loguru.src.exporter import LoguruHandler + +from opentelemetry._logs import set_logger_provider +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.resources import Resource + + + +logger_provider = LoggerProvider( + resource=Resource.create( + { + "service.name": "shoppingcart", + "service.instance.id": "instance-12", + } + ), +) +set_logger_provider(logger_provider) + +# Replace the standard logging configuration with Loguru +loguru_handler = LoguruHandler(service_name="flask-loguru-demo", server_hostname="instance-1", exporter=OTLPLogExporter(insecure=True)) +loguru_logger.add(loguru_handler.sink) # Add LoguruHandler to the logger + +app = Flask(__name__) + +@app.route("/rolldice") +def roll_dice(): + player = request.args.get('player', default=None, type=str) + result = str(roll()) + if player: + loguru_logger.warning("Player is rolling the dice: num") + else: + loguru_logger.warning("Anonymous player is rolling the dice: num") + return result + + +def roll(): + return randint(1, 6) + +``` + +Run the application on port 8080 with the following flask command and open [http://localhost:8080/rolldice](http://localhost:8080/rolldice) in your web browser to ensure it is working. + +``` +flask run -p 8080 +``` + +However, do not be alarmed if you receive these errors since Docker is not yet set up to export the logs: +``` +Transient error StatusCode.UNAVAILABLE encountered while exporting logs to localhost:4317, retrying in 1s. +Transient error StatusCode.UNAVAILABLE encountered while exporting logs to localhost:4317, retrying in 2s. +Transient error StatusCode.UNAVAILABLE encountered while exporting logs to localhost:4317, retrying in 4s. +... +``` + +## Run with Docker + +To serve the application on Docker, first create the `otel-collector-config.yaml` file locally in the application's repository. +``` +# otel-collector-config.yaml +receivers: + otlp: + protocols: + grpc: + +processors: + batch: + +exporters: + logging: + verbosity: detailed + +service: + pipelines: + logs: + receivers: [otlp] + processors: [batch] + exporters: [logging] +``` + +Next, start the Docker container: +``` +docker run \ + -p 4317:4317 \ + -v $(pwd)/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml \ + otel/opentelemetry-collector-contrib:latest +``` + +And lastly, run the basic application with flask: +``` +flask run -p 8080 +``` + +Here is some example output: +``` + +``` + + +## Contributors +Caroline Gilbert: [carolincgilbert](https://github.com/carolinecgilbert) diff --git a/examples/handlers/opentelemetry_loguru/app.py b/examples/handlers/opentelemetry_loguru/app.py new file mode 100644 index 0000000000..2a23dab402 --- /dev/null +++ b/examples/handlers/opentelemetry_loguru/app.py @@ -0,0 +1,37 @@ +from random import randint +from flask import Flask, request +from loguru import logger as loguru_logger +import sys +sys.path.insert(0, '../../..') +from handlers.opentelemetry_loguru.src.exporter import LoguruHandler + +from opentelemetry._logs import set_logger_provider +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.resources import Resource + + + + +# Replace the standard logging configuration with Loguru +loguru_handler = LoguruHandler(service_name="flask-loguru-demo", server_hostname="instance-1", exporter=OTLPLogExporter(insecure=True)) +loguru_logger.add(loguru_handler.sink) # Add LoguruHandler to the logger + + +app = Flask(__name__) + +@app.route("/rolldice") +def roll_dice(): + player = request.args.get('player', default=None, type=str) + result = str(roll()) + if player: + loguru_logger.warning(f"Player {player} is rolling the dice: {result}") + else: + loguru_logger.warning(f"Anonymous player is rolling the dice: {result}") + return result + + +def roll(): + return randint(1, 6) diff --git a/examples/handlers/opentelemetry_loguru/otel-collector-config.yaml b/examples/handlers/opentelemetry_loguru/otel-collector-config.yaml new file mode 100644 index 0000000000..5525cdd849 --- /dev/null +++ b/examples/handlers/opentelemetry_loguru/otel-collector-config.yaml @@ -0,0 +1,19 @@ +# otel-collector-config.yaml +receivers: + otlp: + protocols: + grpc: + +processors: + batch: + +exporters: + logging: + verbosity: detailed + +service: + pipelines: + logs: + receivers: [otlp] + processors: [batch] + exporters: [logging] \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/handlers/opentelemetry_loguru/__init__.py b/handlers/opentelemetry_loguru/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/handlers/opentelemetry_loguru/src/README.md b/handlers/opentelemetry_loguru/src/README.md new file mode 100644 index 0000000000..e06194f51a --- /dev/null +++ b/handlers/opentelemetry_loguru/src/README.md @@ -0,0 +1,53 @@ +# Loguru Handler for OpenTelemetry + +This project provides a Loguru handler for OpenTelemetry applications. The handler converts Loguru logs into the OpenTelemetry Logs Protocol (OTLP) format for export to a collector. + +## Usage + +To use the Loguru handler in your OpenTelemetry application, follow these steps: + +1. Import the necessary modules: + +```python +import loguru +from handlers.opentelemetry_loguru.src.exporter import LoguruHandler +from opentelemetry.sdk._logs._internal.export import LogExporter +from opentelemetry.sdk.resources import Resource +``` + +2. Initialize the LoguruHandler with your service name, server hostname, and LogExporter instance: + +```python +service_name = "my_service" +server_hostname = "my_server" +exporter = LogExporter() # Initialize your LogExporter instance +handler = LoguruHandler(service_name, server_hostname, exporter) +``` + +3. Add the handler to your Loguru logger: + +```python +logger = loguru.logger +logger.add(handler.sink) +``` + +4. Use the logger as usual with Loguru: + +```python +logger.warning("This is a test log message.") +``` +## OpenTelemetry Application Example with Handler +See the loguru handler demo in the examples directory of this repository for a step-by-step guide on using the handler in an OpenTelemetry application. + +## Customization + +The LoguruHandler supports customization through its constructor parameters: + +- `service_name`: The name of your service. +- `server_hostname`: The hostname of the server where the logs originate. +- `exporter`: An instance of your LogExporter for exporting logs to a collector. + +## Notes + +- This handler automatically converts Loguru logs into the OTLP format for compatibility with OpenTelemetry. +- It extracts attributes from Loguru logs and maps them to OpenTelemetry attributes for log records. diff --git a/handlers/opentelemetry_loguru/src/__init__.py b/handlers/opentelemetry_loguru/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/handlers/opentelemetry_loguru/src/exporter.py b/handlers/opentelemetry_loguru/src/exporter.py new file mode 100644 index 0000000000..4b3e475ab5 --- /dev/null +++ b/handlers/opentelemetry_loguru/src/exporter.py @@ -0,0 +1,203 @@ +import traceback +from datetime import datetime, timezone +from typing import Dict + +import loguru +import traceback +from os import environ +from time import time_ns +from typing import Any, Callable, Optional, Tuple, Union # noqa +from opentelemetry._logs import ( + NoOpLogger, + SeverityNumber, + get_logger, + get_logger_provider, + std_to_otel, +) +from opentelemetry.attributes import BoundedAttributes +from opentelemetry.sdk.environment_variables import ( + OTEL_ATTRIBUTE_COUNT_LIMIT, + OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util import ns_to_iso_str +from opentelemetry.sdk.util.instrumentation import InstrumentationScope +from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.trace import ( + format_span_id, + format_trace_id, + get_current_span, +) +from opentelemetry.trace.span import TraceFlags +from opentelemetry.util.types import Attributes + +from opentelemetry._logs import Logger as APILogger +from opentelemetry._logs import LoggerProvider as APILoggerProvider +from opentelemetry._logs import LogRecord as APILogRecord + +from opentelemetry._logs import std_to_otel +from opentelemetry.sdk._logs._internal import LoggerProvider, LogRecord +from opentelemetry.sdk._logs._internal.export import BatchLogRecordProcessor, LogExporter +from opentelemetry.sdk.resources import Resource +from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.trace import get_current_span + +from opentelemetry._logs.severity import SeverityNumber + +import sys +import json + +_STD_TO_OTEL = { + 10: SeverityNumber.DEBUG, + 11: SeverityNumber.DEBUG2, + 12: SeverityNumber.DEBUG3, + 13: SeverityNumber.DEBUG4, + 14: SeverityNumber.DEBUG4, + 15: SeverityNumber.DEBUG4, + 16: SeverityNumber.DEBUG4, + 17: SeverityNumber.DEBUG4, + 18: SeverityNumber.DEBUG4, + 19: SeverityNumber.DEBUG4, + 20: SeverityNumber.INFO, + 21: SeverityNumber.INFO2, + 22: SeverityNumber.INFO3, + 23: SeverityNumber.INFO4, + 24: SeverityNumber.INFO4, + 25: SeverityNumber.INFO4, + 26: SeverityNumber.INFO4, + 27: SeverityNumber.INFO4, + 28: SeverityNumber.INFO4, + 29: SeverityNumber.INFO4, + 30: SeverityNumber.WARN, + 31: SeverityNumber.WARN2, + 32: SeverityNumber.WARN3, + 33: SeverityNumber.WARN4, + 34: SeverityNumber.WARN4, + 35: SeverityNumber.WARN4, + 36: SeverityNumber.WARN4, + 37: SeverityNumber.WARN4, + 38: SeverityNumber.WARN4, + 39: SeverityNumber.WARN4, + 40: SeverityNumber.ERROR, + 41: SeverityNumber.ERROR2, + 42: SeverityNumber.ERROR3, + 43: SeverityNumber.ERROR4, + 44: SeverityNumber.ERROR4, + 45: SeverityNumber.ERROR4, + 46: SeverityNumber.ERROR4, + 47: SeverityNumber.ERROR4, + 48: SeverityNumber.ERROR4, + 49: SeverityNumber.ERROR4, + 50: SeverityNumber.FATAL, + 51: SeverityNumber.FATAL2, + 52: SeverityNumber.FATAL3, + 53: SeverityNumber.FATAL4, +} + +EXCLUDE_ATTR = ("elapsed", "exception", "extra", "file", "level", "process", "thread", "time") +class LoguruHandler: + + # this was largely inspired by the OpenTelemetry handler for stdlib `logging`: + # https://github.com/open-telemetry/opentelemetry-python/blob/8f312c49a5c140c14d1829c66abfe4e859ad8fd7/opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py#L318 + + def __init__( + self, + service_name: str, + server_hostname: str, + exporter: LogExporter, + ) -> None: + logger_provider = LoggerProvider( + resource=Resource.create( + { + "service.name": service_name, + "service.instance.id": server_hostname, + } + ), + ) + + logger_provider.add_log_record_processor( + BatchLogRecordProcessor(exporter, max_export_batch_size=1) + ) + + self._logger_provider = logger_provider + self._logger = logger_provider.get_logger(__name__) + + + def _get_attributes(self, record) -> Attributes: + attributes = {key:value for key, value in record.items() if key not in EXCLUDE_ATTR} + + # Add standard code attributes for logs. + attributes[SpanAttributes.CODE_FILEPATH] = record['file'].path #This includes file and path -> (file, path) + attributes[SpanAttributes.CODE_FUNCTION] = record['function'] + attributes[SpanAttributes.CODE_LINENO] = record['line'] + + attributes['process_name'] = (record['process']).name + attributes['process_id'] = (record['process']).id + attributes['thread_name'] = (record['thread']).name + attributes['thread_id'] = (record['thread']).id + attributes['file'] = record['file'].name + + if record['exception'] is not None: + + attributes[SpanAttributes.EXCEPTION_TYPE] = record['exception'].type + + attributes[SpanAttributes.EXCEPTION_MESSAGE] = record['exception'].value + + attributes[SpanAttributes.EXCEPTION_STACKTRACE] = record['exception'].traceback + + return attributes + + def _loguru_to_otel(self, levelno: int) -> SeverityNumber: + if levelno < 10 or levelno == 25: + return SeverityNumber.UNSPECIFIED + + elif levelno > 53: + return SeverityNumber.FATAL4 + + return _STD_TO_OTEL[levelno] + + + + + def _translate(self, record) -> LogRecord: + + #Timestamp + timestamp = int((record["time"].timestamp()) * 1e9) + + #Observed timestamp + observedTimestamp = time_ns() + + #Span context + spanContext = get_current_span().get_span_context() + + #Setting the level name + if record['level'].name == 'WARNING': + levelName = 'WARN' + elif record['level'].name == 'TRACE' or record['level'].name == 'SUCCESS': + levelName = 'NOTSET' + else: + levelName = record['level'].name + + #Severity number + severityNumber = self._loguru_to_otel(int(record["level"].no)) + + #Getting attributes + attributes = self._get_attributes(record) + + + return LogRecord( + timestamp = timestamp, + observed_timestamp = observedTimestamp, + trace_id = spanContext.trace_id, + span_id = spanContext.span_id, + trace_flags = spanContext.trace_flags, + severity_text = levelName, + severity_number = severityNumber, + body=record['message'], + resource = self._logger.resource, + attributes=attributes + ) + + def sink(self, record) -> None: + self._logger.emit(self._translate(record.record)) + \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt index 600d066cc1..d629dae5b9 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt +++ b/instrumentation/opentelemetry-instrumentation-logging/test-requirements.txt @@ -10,5 +10,7 @@ tomli==2.0.1 typing_extensions==4.12.2 wrapt==1.16.0 zipp==3.19.2 +loguru==0.7.2 +opentelemetry-exporter-otlp==1.24.0 -e opentelemetry-instrumentation -e instrumentation/opentelemetry-instrumentation-logging diff --git a/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py b/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py index c8b8744cf3..d876148ed0 100644 --- a/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py +++ b/instrumentation/opentelemetry-instrumentation-logging/tests/test_logging.py @@ -15,15 +15,34 @@ import logging from typing import Optional from unittest import mock +from collections import namedtuple import pytest + +from unittest.mock import MagicMock, patch +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) + + +from opentelemetry.semconv.trace import SpanAttributes + from opentelemetry.instrumentation.logging import ( # pylint: disable=no-name-in-module DEFAULT_LOGGING_FORMAT, LoggingInstrumentor, ) from opentelemetry.test.test_base import TestBase -from opentelemetry.trace import NoOpTracerProvider, ProxyTracer, get_tracer +from opentelemetry.trace import ( + NoOpTracerProvider, + ProxyTracer, + get_tracer, + get_current_span, +) + +from handlers.opentelemetry_loguru.src.exporter import LoguruHandler, _STD_TO_OTEL + +from opentelemetry._logs import get_logger_provider class FakeTracerProvider: @@ -99,9 +118,7 @@ def test_trace_context_injection(self): span_id = format(span.get_span_context().span_id, "016x") trace_id = format(span.get_span_context().trace_id, "032x") trace_sampled = span.get_span_context().trace_flags.sampled - self.assert_trace_context_injected( - span_id, trace_id, trace_sampled - ) + self.assert_trace_context_injected(span_id, trace_id, trace_sampled) def test_trace_context_injection_without_span(self): self.assert_trace_context_injected("0", "0", False) @@ -146,9 +163,7 @@ def test_custom_format_and_level_env(self, basic_config_mock): env_patch.stop() @mock.patch("logging.basicConfig") - def test_custom_format_and_level_api( - self, basic_config_mock - ): # pylint: disable=no-self-use + def test_custom_format_and_level_api(self, basic_config_mock): # pylint: disable=no-self-use LoggingInstrumentor().uninstrument() LoggingInstrumentor().instrument( set_logging_format=True, @@ -187,9 +202,7 @@ def test_uninstrumented(self): span_id = format(span.get_span_context().span_id, "016x") trace_id = format(span.get_span_context().trace_id, "032x") trace_sampled = span.get_span_context().trace_flags.sampled - self.assert_trace_context_injected( - span_id, trace_id, trace_sampled - ) + self.assert_trace_context_injected(span_id, trace_id, trace_sampled) LoggingInstrumentor().uninstrument() @@ -208,6 +221,155 @@ def test_uninstrumented(self): self.assertFalse(hasattr(record, "otelServiceName")) self.assertFalse(hasattr(record, "otelTraceSampled")) + +class TimestampRecord: + def __init__(self, data): + self.timestam = data + + def timestamp(self): + return self.timestam + + +class TestLoguruHandler(TestBase): + def setUp(self): + self.default_provider = get_logger_provider() + self.custom_provider = MagicMock() + + RecordFile = namedtuple("RecordFile", ["path", "name"]) + file_record = RecordFile(path="test_file.py", name="test_file.py") + + RecordProcess = namedtuple("RecordProcess", ["name", "id"]) + process_record = RecordProcess(name="MainProcess", id=1) + + RecordThread = namedtuple("RecordThread", ["name", "id"]) + thread_record = RecordThread(name="MainThread", id=1) + + timeRec = TimestampRecord(data=2.38763786) + + self.record = { + "time": timeRec, + "level": MagicMock(name="ERROR", no=40), + "message": "Test message", + "file": file_record, + "process": process_record, + "thread": thread_record, + "function": "test_function", + "line": 123, + "exception": None, + } + + self.span_context = get_current_span().get_span_context() + self.current_span = MagicMock() + self.current_span.get_span_context.return_value = self.span_context + + def test_attributes_extraction_without_exception(self): + handler = LoguruHandler( + service_name="flask-loguru-demo", + server_hostname="instance-1", + exporter=OTLPLogExporter(insecure=True), + ) + + attrs = handler._get_attributes(self.record) + + expected_attrs = { + SpanAttributes.CODE_FILEPATH: "test_file.py", + SpanAttributes.CODE_FUNCTION: "test_function", + SpanAttributes.CODE_LINENO: 123, + } + + self.assertEqual( + attrs[SpanAttributes.CODE_FILEPATH], + expected_attrs[SpanAttributes.CODE_FILEPATH], + ) + self.assertEqual( + attrs[SpanAttributes.CODE_FUNCTION], + expected_attrs[SpanAttributes.CODE_FUNCTION], + ) + self.assertEqual( + attrs[SpanAttributes.CODE_LINENO], + expected_attrs[SpanAttributes.CODE_LINENO], + ) + + @patch("traceback.format_exception") + def test_attributes_extraction_with_exception(self, mock_format_exception): + mock_format_exception.return_value = "Exception traceback" + exception = Exception("Test exception") + + ExceptionRecord = namedtuple("ExceptionRecord", ["type", "value", "traceback"]) + + # Example usage: + exception_record = ExceptionRecord( + type=type(exception).__name__, + value=str(exception), + traceback=mock_format_exception(exception), + ) + self.record["exception"] = exception_record + + handler = LoguruHandler( + service_name="flask-loguru-demo", + server_hostname="instance-1", + exporter=OTLPLogExporter(insecure=True), + ) + + attrs = handler._get_attributes(self.record) + + expected_attrs = { + SpanAttributes.CODE_FILEPATH: "test_file.py", + SpanAttributes.CODE_FUNCTION: "test_function", + SpanAttributes.CODE_LINENO: 123, + SpanAttributes.EXCEPTION_TYPE: "Exception", + SpanAttributes.EXCEPTION_MESSAGE: "Test exception", + SpanAttributes.EXCEPTION_STACKTRACE: "Exception traceback", + } + + self.assertEqual( + attrs[SpanAttributes.EXCEPTION_TYPE], + expected_attrs[SpanAttributes.EXCEPTION_TYPE], + ) + self.assertEqual( + attrs[SpanAttributes.EXCEPTION_MESSAGE], + expected_attrs[SpanAttributes.EXCEPTION_MESSAGE], + ) + self.assertEqual( + attrs[SpanAttributes.EXCEPTION_STACKTRACE], + expected_attrs[SpanAttributes.EXCEPTION_STACKTRACE], + ) + + @patch("opentelemetry.trace.get_current_span") + def test_translation(self, mock_get_current_span): + mock_get_current_span.return_value = self.current_span + + handler = LoguruHandler( + service_name="flask-loguru-demo", + server_hostname="instance-1", + exporter=OTLPLogExporter(insecure=True), + ) + + log_record = handler._translate(self.record) + self.assertEqual(log_record.trace_id, self.span_context.trace_id) + self.assertEqual(log_record.span_id, self.span_context.span_id) + self.assertEqual(log_record.trace_flags, self.span_context.trace_flags) + self.assertEqual( + log_record.severity_number, _STD_TO_OTEL[self.record["level"].no] + ) + self.assertEqual(log_record.body, self.record["message"]) + + @patch("opentelemetry._logs.Logger.emit") + @patch("opentelemetry.trace.get_current_span") + def test_sink(self, mock_get_current_span, mock_emit): + mock_get_current_span.return_value = self.current_span + + handler = LoguruHandler( + service_name="flask-loguru-demo", + server_hostname="instance-1", + exporter=OTLPLogExporter(insecure=True), + ) + + MessageRecord = namedtuple("MessageRecord", ["record"]) + message = MessageRecord(record=self.record) + + handler.sink(message) + def test_no_op_tracer_provider(self): LoggingInstrumentor().uninstrument() LoggingInstrumentor().instrument(tracer_provider=NoOpTracerProvider()) diff --git a/tox.ini b/tox.ini index 5bf05d451f..1ee7e81fc9 100644 --- a/tox.ini +++ b/tox.ini @@ -396,6 +396,7 @@ setenv = ; i.e: CORE_REPO_SHA=dde62cebffe519c35875af6d06fae053b3be65ec tox -e CORE_REPO_SHA={env:CORE_REPO_SHA:main} CORE_REPO=git+https://github.com/open-telemetry/opentelemetry-python.git@{env:CORE_REPO_SHA} + PYTHONPATH={toxinidir} commands_pre = opentelemetry-instrumentation: pip install opentelemetry-api@{env:CORE_REPO}\#egg=opentelemetry-api&subdirectory=opentelemetry-api