From 7c01b5a0400773da979d09993619c966a0269abb Mon Sep 17 00:00:00 2001 From: Ali Saleh Date: Thu, 5 Dec 2024 21:09:02 +0500 Subject: [PATCH] feat: Enhance Observability with ArizePhoenixTracer Integration (#5058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added ArizePhoenixTracer * [autofix.ci] apply automated fixes * Fixed Ruff Style Check Errors * add Arize icon * Fixed Mypy Errors * Refactor ArizeIcon component to use arrow function syntax Co-authored-by: Jeff Carpenter Co-authored-by: Ítalo Johnny Co-authored-by: anovazzi1 --- pyproject.toml | 2 + .../services/tracing/arize_phoenix.py | 324 ++++++++++++++++++ .../base/langflow/services/tracing/service.py | 17 + src/frontend/src/icons/Arize/Arize.jsx | 20 ++ src/frontend/src/icons/Arize/arize.svg | 6 + src/frontend/src/icons/Arize/index.tsx | 8 + src/frontend/src/utils/styleUtils.ts | 2 + uv.lock | 74 ++++ 8 files changed, 453 insertions(+) create mode 100644 src/backend/base/langflow/services/tracing/arize_phoenix.py create mode 100644 src/frontend/src/icons/Arize/Arize.jsx create mode 100644 src/frontend/src/icons/Arize/arize.svg create mode 100644 src/frontend/src/icons/Arize/index.tsx diff --git a/pyproject.toml b/pyproject.toml index 1b16eb854..388bf2331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,8 @@ dependencies = [ "mem0ai>=0.1.26,<1.0.0", "aiofile>=3.9.0,<4.0.0", "sseclient-py==1.8.0", + "arize-phoenix-otel>=0.6.1", + "openinference-instrumentation-langchain>=0.1.29", ] [project.urls] diff --git a/src/backend/base/langflow/services/tracing/arize_phoenix.py b/src/backend/base/langflow/services/tracing/arize_phoenix.py new file mode 100644 index 000000000..6e4b03f96 --- /dev/null +++ b/src/backend/base/langflow/services/tracing/arize_phoenix.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import json +import math +import os +import traceback +import types +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from langchain_core.documents import Document +from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage +from loguru import logger +from openinference.semconv.trace import OpenInferenceMimeTypeValues, SpanAttributes +from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes +from opentelemetry.trace import Span, Status, StatusCode +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +from typing_extensions import override + +from langflow.schema.data import Data +from langflow.schema.message import Message +from langflow.services.tracing.base import BaseTracer + +if TYPE_CHECKING: + from collections.abc import Sequence + from uuid import UUID + + from langchain.callbacks.base import BaseCallbackHandler + from opentelemetry.propagators.textmap import CarrierT + from opentelemetry.util.types import AttributeValue + + from langflow.graph.vertex.base import Vertex + from langflow.services.tracing.schema import Log + + +class ArizePhoenixTracer(BaseTracer): + flow_id: str + + def __init__(self, trace_name: str, trace_type: str, project_name: str, trace_id: UUID): + """Initializes the ArizePhoenixTracer instance and sets up a root span.""" + self.trace_name = trace_name + self.trace_type = trace_type + self.project_name = project_name + self.trace_id = trace_id + self.flow_id = trace_name.split(" - ")[-1] + + try: + self._ready = self.setup_arize_phoenix() + if not self._ready: + return + + self.tracer = self.tracer_provider.get_tracer(__name__) + self.propagator = TraceContextTextMapPropagator() + self.carrier: dict[Any, CarrierT] = {} + + with self.tracer.start_as_current_span( + name=self.flow_id, + start_time=self._get_current_timestamp(), + ) as root_span: + root_span.set_attribute(SpanAttributes.OPENINFERENCE_SPAN_KIND, self.trace_type) + root_span.set_status(Status(StatusCode.OK)) + self.propagator.inject(carrier=self.carrier) + + self.child_spans: dict[str, Span] = {} + + except Exception: # noqa: BLE001 + logger.opt(exception=True).debug("Error setting up Arize/Phoenix tracer") + self._ready = False + + @property + def ready(self): + """Indicates if the tracer is ready for usage.""" + return self._ready + + def setup_arize_phoenix(self) -> bool: + """Configures Arize/Phoenix specific environment variables and registers the tracer provider.""" + arize_phoenix_batch = os.getenv("ARIZE_PHOENIX_BATCH", "False").lower() in ( + "true", + "t", + "yes", + "y", + "1", + ) + + # Arize Config + arize_api_key = os.getenv("ARIZE_API_KEY", None) + arize_space_id = os.getenv("ARIZE_SPACE_ID", None) + arize_collector_endpoint = os.getenv("ARIZE_COLLECTOR_ENDPOINT", "https://otlp.arize.com") + enable_arize_tracing = bool(arize_api_key and arize_space_id) + arize_endpoint = f"{arize_collector_endpoint}/v1" + arize_headers = { + "api_key": arize_api_key, + "space_id": arize_space_id, + "authorization": f"Bearer {arize_api_key}", + } + + # Phoenix Config + phoenix_api_key = os.getenv("PHOENIX_API_KEY", None) + phoenix_collector_endpoint = os.getenv("PHOENIX_COLLECTOR_ENDPOINT", "https://app.phoenix.arize.com") + enable_phoenix_tracing = bool(phoenix_api_key) + phoenix_endpoint = f"{phoenix_collector_endpoint}/v1/traces" + phoenix_headers = { + "api_key": phoenix_api_key, + "authorization": f"Bearer {phoenix_api_key}", + } + + if not (enable_arize_tracing or enable_phoenix_tracing): + return False + + try: + from phoenix.otel import ( + PROJECT_NAME, + BatchSpanProcessor, + GRPCSpanExporter, + HTTPSpanExporter, + Resource, + SimpleSpanProcessor, + TracerProvider, + ) + + project_name = self.project_name or self.flow_id + attributes = {PROJECT_NAME: project_name, "model_id": project_name} + resource = Resource.create(attributes=attributes) + tracer_provider = TracerProvider(resource=resource, verbose=False) + span_processor = BatchSpanProcessor if arize_phoenix_batch else SimpleSpanProcessor + + if enable_arize_tracing: + tracer_provider.add_span_processor( + span_processor=span_processor( + span_exporter=GRPCSpanExporter(endpoint=arize_endpoint, headers=arize_headers), + ) + ) + + if enable_phoenix_tracing: + tracer_provider.add_span_processor( + span_processor=span_processor( + span_exporter=HTTPSpanExporter( + endpoint=phoenix_endpoint, + headers=phoenix_headers, + ), + ) + ) + + self.tracer_provider = tracer_provider + except ImportError: + logger.exception( + "Could not import arize-phoenix-otel. Please install it with `pip install arize-phoenix-otel`." + ) + return False + + try: + from openinference.instrumentation.langchain import LangChainInstrumentor + + LangChainInstrumentor().instrument(tracer_provider=self.tracer_provider, skip_dep_check=True) + except ImportError: + logger.exception( + "Could not import LangChainInstrumentor." + "Please install it with `pip install openinference-instrumentation-langchain`." + ) + return False + + return True + + @override + def add_trace( + self, + trace_id: str, + trace_name: str, + trace_type: str, + inputs: dict[str, Any], + metadata: dict[str, Any] | None = None, + vertex: Vertex | None = None, + ) -> None: + """Adds a trace span, attaching inputs and metadata as attributes.""" + if not self._ready: + return + + span_context = self.propagator.extract(carrier=self.carrier) + child_span = self.tracer.start_span( + name=trace_name, + context=span_context, + start_time=self._get_current_timestamp(), + ) + + if trace_type == "prompt": + child_span.set_attribute(SpanAttributes.OPENINFERENCE_SPAN_KIND, "chain") + else: + child_span.set_attribute(SpanAttributes.OPENINFERENCE_SPAN_KIND, trace_type) + + if "session_id" in inputs and len(inputs["session_id"]) > 0 and inputs["session_id"] != self.flow_id: + child_span.set_attribute(SpanAttributes.SESSION_ID, inputs["session_id"]) + else: + child_span.set_attribute(SpanAttributes.SESSION_ID, self.flow_id) + + processed_inputs = self._convert_to_arize_phoenix_types(inputs) if inputs else {} + if processed_inputs: + child_span.set_attribute(SpanAttributes.INPUT_VALUE, self._safe_json_dumps(processed_inputs)) + child_span.set_attribute(SpanAttributes.INPUT_MIME_TYPE, OpenInferenceMimeTypeValues.JSON.value) + + processed_metadata = self._convert_to_arize_phoenix_types(metadata) if metadata else {} + if processed_metadata: + for key, value in processed_metadata.items(): + child_span.set_attribute(f"{SpanAttributes.METADATA}.{key}", value) + + self.child_spans[trace_id] = child_span + + @override + def end_trace( + self, + trace_id: str, + trace_name: str, + outputs: dict[str, Any] | None = None, + error: Exception | None = None, + logs: Sequence[Log | dict] = (), + ) -> None: + """Ends a trace span, attaching outputs, errors, and logs as attributes.""" + if not self._ready or trace_id not in self.child_spans: + return + + child_span = self.child_spans[trace_id] + + processed_outputs = self._convert_to_arize_phoenix_types(outputs) if outputs else {} + if processed_outputs: + child_span.set_attribute(SpanAttributes.OUTPUT_VALUE, self._safe_json_dumps(processed_outputs)) + child_span.set_attribute(SpanAttributes.OUTPUT_MIME_TYPE, OpenInferenceMimeTypeValues.JSON.value) + + logs_dicts = [log if isinstance(log, dict) else log.model_dump() for log in logs] + processed_logs = ( + self._convert_to_arize_phoenix_types({log.get("name"): log for log in logs_dicts}) if logs else {} + ) + if processed_logs: + for key, value in processed_logs.items(): + child_span.set_attribute(f"logs.{key}", value) + + if error: + error_string = self._error_to_string(error) + child_span.set_status(Status(StatusCode.ERROR, error_string)) + child_span.set_attribute("error.message", error_string) + + if isinstance(error, Exception): + child_span.record_exception(error) + else: + exception_type = error.__class__.__name__ + exception_message = str(error) + if not exception_message: + exception_message = repr(error) + attributes: dict[str, AttributeValue] = { + OTELSpanAttributes.EXCEPTION_TYPE: exception_type, + OTELSpanAttributes.EXCEPTION_MESSAGE: exception_message, + OTELSpanAttributes.EXCEPTION_ESCAPED: False, + OTELSpanAttributes.EXCEPTION_STACKTRACE: error_string, + } + child_span.add_event(name="exception", attributes=attributes) + else: + child_span.set_status(Status(StatusCode.OK)) + + child_span.end(end_time=self._get_current_timestamp()) + self.child_spans.pop(trace_id) + + @override + def end( + self, + inputs: dict[str, Any], + outputs: dict[str, Any], + error: Exception | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + """Ends tracing with the specified inputs, outputs, errors, and metadata as attributes.""" + if not self._ready: + return + + def _convert_to_arize_phoenix_types(self, io_dict: dict[str, Any]): + """Converts data types to Arize/Phoenix compatible formats.""" + return {key: self._convert_to_arize_phoenix_type(value) for key, value in io_dict.items()} + + def _convert_to_arize_phoenix_type(self, value): + """Recursively converts a value to a Arize/Phoenix compatible type.""" + if isinstance(value, dict): + for key, _value in value.copy().items(): + _value = self._convert_to_arize_phoenix_type(_value) + value[key] = _value + + elif isinstance(value, list): + value = [self._convert_to_arize_phoenix_type(v) for v in value] + + elif isinstance(value, Message): + value = value.text + + elif isinstance(value, Data): + value = value.get_text() + + elif isinstance(value, (BaseMessage | HumanMessage | SystemMessage)): + value = value.content + + elif isinstance(value, Document): + value = value.page_content + + elif isinstance(value, (types.GeneratorType | types.NoneType)): + value = str(value) + + elif isinstance(value, float) and not math.isfinite(value): + value = "NaN" + + return value + + def _error_to_string(self, error: Exception | None): + """Converts an error to a string with traceback details.""" + error_message = None + if error: + string_stacktrace = traceback.format_exception(error) + error_message = f"{error.__class__.__name__}: {error}\n\n{string_stacktrace}" + return error_message + + def _get_current_timestamp(self) -> int: + """Gets the current UTC timestamp in nanoseconds.""" + return int(datetime.now(timezone.utc).timestamp() * 1_000_000_000) + + def _safe_json_dumps(self, obj: Any, **kwargs: Any) -> str: + """A convenience wrapper around `json.dumps` that ensures that any object can be safely encoded.""" + return json.dumps(obj, default=str, ensure_ascii=False, **kwargs) + + def get_langchain_callback(self) -> BaseCallbackHandler | None: + """Returns the LangChain callback handler if applicable.""" + return None diff --git a/src/backend/base/langflow/services/tracing/service.py b/src/backend/base/langflow/services/tracing/service.py index bd146dbe3..90119558a 100644 --- a/src/backend/base/langflow/services/tracing/service.py +++ b/src/backend/base/langflow/services/tracing/service.py @@ -40,6 +40,12 @@ def _get_langfuse_tracer(): return LangFuseTracer +def _get_arize_phoenix_tracer(): + from langflow.services.tracing.arize_phoenix import ArizePhoenixTracer + + return ArizePhoenixTracer + + class TracingService(Service): name = "tracing_service" @@ -111,6 +117,7 @@ class TracingService(Service): await asyncio.to_thread(self._initialize_langsmith_tracer) await asyncio.to_thread(self._initialize_langwatch_tracer) await asyncio.to_thread(self._initialize_langfuse_tracer) + await asyncio.to_thread(self._initialize_arize_phoenix_tracer) except Exception as e: # noqa: BLE001 logger.debug(f"Error initializing tracers: {e}") @@ -145,6 +152,16 @@ class TracingService(Service): trace_id=self.run_id, ) + def _initialize_arize_phoenix_tracer(self) -> None: + self.project_name = os.getenv("ARIZE_PHOENIX_PROJECT", "Langflow") + arize_phoenix_tracer = _get_arize_phoenix_tracer() + self._tracers["arize_phoenix"] = arize_phoenix_tracer( + trace_name=self.run_name, + trace_type="chain", + project_name=self.project_name, + trace_id=self.run_id, + ) + def set_run_name(self, name: str) -> None: self.run_name = name diff --git a/src/frontend/src/icons/Arize/Arize.jsx b/src/frontend/src/icons/Arize/Arize.jsx new file mode 100644 index 000000000..04b9e626c --- /dev/null +++ b/src/frontend/src/icons/Arize/Arize.jsx @@ -0,0 +1,20 @@ +const SvgArize = (props) => ( + + + + +); + +export default SvgArize; diff --git a/src/frontend/src/icons/Arize/arize.svg b/src/frontend/src/icons/Arize/arize.svg new file mode 100644 index 000000000..57b6ca936 --- /dev/null +++ b/src/frontend/src/icons/Arize/arize.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/frontend/src/icons/Arize/index.tsx b/src/frontend/src/icons/Arize/index.tsx new file mode 100644 index 000000000..c47357430 --- /dev/null +++ b/src/frontend/src/icons/Arize/index.tsx @@ -0,0 +1,8 @@ +import React, { forwardRef } from "react"; +import SvgArize from "./Arize"; + +export const ArizeIcon = forwardRef>( + (props, ref) => { + return ; + }, +); diff --git a/src/frontend/src/utils/styleUtils.ts b/src/frontend/src/utils/styleUtils.ts index f6e4eb9d7..e54ed5bea 100644 --- a/src/frontend/src/utils/styleUtils.ts +++ b/src/frontend/src/utils/styleUtils.ts @@ -233,6 +233,7 @@ import { FaApple, FaDiscord, FaGithub } from "react-icons/fa"; import { AWSIcon } from "../icons/AWS"; import { AirbyteIcon } from "../icons/Airbyte"; import { AnthropicIcon } from "../icons/Anthropic"; +import { ArizeIcon } from "../icons/Arize"; import { AssemblyAIIcon } from "../icons/AssemblyAI"; import { AstraDBIcon } from "../icons/AstraDB"; import { AzureIcon } from "../icons/Azure"; @@ -704,6 +705,7 @@ export const nodeIconsLucide: iconsType = { SerpSearch: SerpSearchIcon, SearchAPI: SearchAPIIcon, Wikipedia: WikipediaIcon, + Arize: ArizeIcon, //Node Icons model_specs: FileSliders, diff --git a/uv.lock b/uv.lock index af859f27d..a88935f79 100644 --- a/uv.lock +++ b/uv.lock @@ -215,6 +215,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 }, ] +[[package]] +name = "arize-phoenix-otel" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-exporter-otlp" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/70/41eef869b46882aae54e9b8c757223da93160633bba82752158a77b1566a/arize_phoenix_otel-0.6.1.tar.gz", hash = "sha256:f5f7a257159f49b398dfe2ba45bb70e22766cdf61bb365c0c47dc55f329bfe95", size = 9691 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/f3/f2390a9d7b3ee46b28a8ccee6510a489ae0974f31b51c67d79cab4c45ef8/arize_phoenix_otel-0.6.1-py3-none-any.whl", hash = "sha256:fc00dd847a248cece5f20dffeb8fabfab72c1c38f198bcee4260618c6af1d8a5", size = 10783 }, +] + [[package]] name = "asgi-lifespan" version = "2.1.0" @@ -3522,6 +3539,7 @@ version = "1.1.1" source = { editable = "." } dependencies = [ { name = "aiofile" }, + { name = "arize-phoenix-otel" }, { name = "assemblyai" }, { name = "astra-assistants", extra = ["tools"] }, { name = "atlassian-python-api" }, @@ -3578,6 +3596,7 @@ dependencies = [ { name = "networkx" }, { name = "nltk" }, { name = "numexpr" }, + { name = "openinference-instrumentation-langchain" }, { name = "opensearch-py" }, { name = "pgvector" }, { name = "pyarrow" }, @@ -3666,6 +3685,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiofile", specifier = ">=3.9.0,<4.0.0" }, + { name = "arize-phoenix-otel", specifier = ">=0.6.1" }, { name = "assemblyai", specifier = ">=0.33.0,<1.0.0" }, { name = "astra-assistants", extras = ["tools"], specifier = "~=2.2.6" }, { name = "atlassian-python-api", specifier = ">=3.41.16,<4.0.0" }, @@ -3730,6 +3750,7 @@ requires-dist = [ { name = "networkx", specifier = ">=3.1,<4.0.0" }, { name = "nltk", specifier = ">=3.9.1,<4.0.0" }, { name = "numexpr", specifier = ">=2.8.6,<3.0.0" }, + { name = "openinference-instrumentation-langchain", specifier = ">=0.1.29" }, { name = "opensearch-py", specifier = ">=2.7.1,<3.0.0" }, { name = "pgvector", specifier = ">=0.2.3,<1.0.0" }, { name = "pyarrow", specifier = ">=14.0.0,<18.0.0" }, @@ -4931,6 +4952,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/1e/9dc3ccee95d0e16e54e353d3c355bb7cc506d56a2dbb0a07bc739cc48eac/openai-1.52.0-py3-none-any.whl", hash = "sha256:0c249f20920183b0a2ca4f7dba7b0452df3ecd0fa7985eb1d91ad884bc3ced9c", size = 386947 }, ] +[[package]] +name = "openinference-instrumentation" +version = "0.1.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/96/b2c98e52caf35d1f9f1e5499646498667afbf17155c7ea8e990956bca98b/openinference_instrumentation-0.1.19.tar.gz", hash = "sha256:48e91e655dfe49a320f898bb1ec9e68d919e2e222b152cd33366fc94502e9a27", size = 11801 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/5c/5632de07c9ec5a18f9f16c1b50814cfee46cdefca9ff9417e859a42c38f8/openinference_instrumentation-0.1.19-py3-none-any.whl", hash = "sha256:f1a6e31c0c7668f5e3849f7d4a0156409456f35ea1cb19d7495b31f2f5b7d3e5", size = 14569 }, +] + +[[package]] +name = "openinference-instrumentation-langchain" +version = "0.1.29" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "openinference-instrumentation" }, + { name = "openinference-semantic-conventions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/d2/185e4dd18431f35faba90482f511328d283e71ed12737e050664f7b5efeb/openinference_instrumentation_langchain-0.1.29.tar.gz", hash = "sha256:f6c10079c91f810cff39ff24c278e41d16df0c3706b230e1859f46cad20f8e0b", size = 45868 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/ba/b0855a5f9bb565e462d85ecfad63d108c4c4bc209a34850623273f7dbcef/openinference_instrumentation_langchain-0.1.29-py3-none-any.whl", hash = "sha256:9faaedb62f90ca8099b48c0b9ded97fe109a646b756f4bf7c98479b500a8446d", size = 17043 }, +] + +[[package]] +name = "openinference-semantic-conventions" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/ae/0bf5e0dcda695fd66428da05880e137c67f6fcbc494b31edec72635dc9e1/openinference_semantic_conventions-0.1.12.tar.gz", hash = "sha256:f1a378429873128892f44505a994e2b29277a8dc1be9a56b1726b1069d70dcdc", size = 9240 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/fb/ea6c04bb12b2e5ade3faf32b24d45ffce9a21a1468ea179de96ceec1fb3d/openinference_semantic_conventions-0.1.12-py3-none-any.whl", hash = "sha256:3489262a148ba25b3203e500dedcf8955f0c68072f7e45db3a958c0d12585e62", size = 9061 }, +] + [[package]] name = "opensearch-py" version = "2.7.1" @@ -4960,6 +5021,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/1f/737dcdbc9fea2fa96c1b392ae47275165a7c641663fbb08a8d252968eed2/opentelemetry_api-1.27.0-py3-none-any.whl", hash = "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7", size = 63970 }, ] +[[package]] +name = "opentelemetry-exporter-otlp" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/d3/8156cc14e8f4573a3572ee7f30badc7aabd02961a09acc72ab5f2c789ef1/opentelemetry_exporter_otlp-1.27.0.tar.gz", hash = "sha256:4a599459e623868cc95d933c301199c2367e530f089750e115599fccd67cb2a1", size = 6166 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/6d/95e1fc2c8d945a734db32e87a5aa7a804f847c1657a21351df9338bd1c9c/opentelemetry_exporter_otlp-1.27.0-py3-none-any.whl", hash = "sha256:7688791cbdd951d71eb6445951d1cfbb7b6b2d7ee5948fac805d404802931145", size = 7001 }, +] + [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.27.0"