diff --git a/.codespellrc b/.codespellrc index e20054be5..f85d0dafd 100644 --- a/.codespellrc +++ b/.codespellrc @@ -3,5 +3,5 @@ [codespell] check-hidden = true # skipping auto generated folders -skip = ./.git,./.tox,./.venv,./.mypy_cache,./docs/_build,./target,*/LICENSE,./venv,*/cassettes +skip = ./.git,./.tox,./.venv,./test_env,./.mypy_cache,./docs/_build,./target,*/LICENSE,./venv,*/cassettes ignore-words-list = ot diff --git a/instrumentation/README.md b/instrumentation/README.md index 228b4672a..dc6e53668 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -4,6 +4,7 @@ | [opentelemetry-instrumentation-genai-agno](./opentelemetry-instrumentation-genai-agno) | agno >= 2.0.0 | No | development | [opentelemetry-instrumentation-genai-anthropic](./opentelemetry-instrumentation-genai-anthropic) | anthropic >= 0.16.0 | No | development | [opentelemetry-instrumentation-genai-claude-agent-sdk](./opentelemetry-instrumentation-genai-claude-agent-sdk) | claude-agent-sdk >= 0.1.14 | No | development +| [opentelemetry-instrumentation-genai-groq](./opentelemetry-instrumentation-genai-groq) | groq >= 0.6.0 | No | development | [opentelemetry-instrumentation-genai-langchain](./opentelemetry-instrumentation-genai-langchain) | langchain >= 0.3.21 | No | development | [opentelemetry-instrumentation-genai-llama-index](./opentelemetry-instrumentation-genai-llama-index) | llama-index-core >= 0.14.19 | No | development | [opentelemetry-instrumentation-genai-openai](./opentelemetry-instrumentation-genai-openai) | openai >= 1.26.0 | Yes | development diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/.changelog/313.added b/instrumentation/opentelemetry-instrumentation-genai-groq/.changelog/313.added new file mode 100644 index 000000000..8d669868e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/.changelog/313.added @@ -0,0 +1 @@ +Add `opentelemetry-instrumentation-genai-groq` package for instrumenting the official Groq Python client. diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/README.rst b/instrumentation/opentelemetry-instrumentation-genai-groq/README.rst new file mode 100644 index 000000000..58b3d91e7 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/README.rst @@ -0,0 +1,114 @@ +OpenTelemetry Groq Instrumentation +================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-genai-groq.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-genai-groq/ + +This library allows tracing LLM requests and logging of messages made by the +`Groq Python API library `_. It also captures +the duration of the operations and the number of tokens used as metrics. + +Installation +------------ + +If your application is already instrumented with OpenTelemetry, add this +package to your requirements. +:: + + pip install opentelemetry-instrumentation-genai-groq + +If you don't have a Groq application, yet, try our `examples `_ +which only need a valid Groq API key. + +Check out `zero-code example `_ for a quick start. + +Usage +----- + +This section describes how to set up Groq instrumentation if you're setting OpenTelemetry up manually. +Check out the `manual example `_ for more details. + +Instrumenting all clients +************************* + +When using the instrumentor, all clients will automatically trace Groq operations including chat completions. +You can also optionally capture prompts and completions as log events. + +Make sure to configure OpenTelemetry tracing, logging, and events to capture all telemetry emitted by the instrumentation. + +.. code-block:: python + + from opentelemetry.instrumentation.genai.groq import GroqInstrumentor + from groq import Groq + + GroqInstrumentor().instrument() + + client = Groq() + # Chat completion example + response = client.chat.completions.create( + model="llama3-8b-8192", + messages=[ + {"role": "user", "content": "Write a short poem on open telemetry."}, + ], + ) + +Enabling message content +************************* + +Message content such as the contents of the prompt, completion, function arguments and return values +are not captured by default. To capture message content, set the environment variable +``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` to one of the following values: + +- ``span_only`` - capture content on *span* attributes. +- ``event_only`` - capture content on *event* attributes. +- ``span_and_event`` - capture content on both *span* and *event* attributes. +- ``no_content`` - do not capture content (the default). + +Uploading prompts and completions +********************************* + +To enable the built-in upload hook, set: + +- ``OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload`` +- ``OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH`` to an ``fsspec``-compatible URI/path + (e.g. ``/path/to/prompts`` or ``gs://my_bucket``). + +Install the ``upload`` extra to pull in ``fsspec``:: + + pip install opentelemetry-util-genai[upload] + +See the `opentelemetry-util-genai +`_ +for additional options. + +Enabling the latest experimental features +*********************************************** + +The latest experimental GenAI semantic conventions are used unconditionally; there is +no environment variable to opt in or out. + +.. note:: Generative AI semantic conventions are still evolving. The latest experimental features may introduce breaking changes in future releases. + +Uninstrument +************ + +To uninstrument clients, call the uninstrument method: + +.. code-block:: python + + from opentelemetry.instrumentation.genai.groq import GroqInstrumentor + + GroqInstrumentor().instrument() + # ... + + # Uninstrument all clients + GroqInstrumentor().uninstrument() + +References +---------- + +* `OpenTelemetry Project `_ +* `OpenTelemetry GenAI semantic conventions `_ +* `Groq SDK (Python) `_ diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/.env b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/.env new file mode 100644 index 000000000..28cc4d645 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/.env @@ -0,0 +1,26 @@ +# TODO: Set your Groq API key here +GROQ_API_KEY=sk-YOUR_API_KEY + +# Uncomment to use Ollama instead of Groq +# GROQ_BASE_URL=http://localhost:11434/v1 +# GROQ_API_KEY=unused +# CHAT_MODEL=qwen2.5:0.5b + +# Uncomment and change to your OTLP endpoint +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# OTEL_EXPORTER_OTLP_PROTOCOL=grpc + +OTEL_SERVICE_NAME=opentelemetry-python-groq + +# Remove to hide prompt and completion content +# Possible values (case insensitive): +# - `span_only` - record content on span attributes +# - `event_only` - record content on event attributes +# - `span_and_event` - record content on both span and event attributes +# - everything else - don't record content on any signal +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only + +# Uncomment to upload prompts and responses to an fsspec-compatible +# destination instead of or in addition to recording them inline on spans/events. +# OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload +# OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH=/path/to/prompts diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/README.rst b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/README.rst new file mode 100644 index 000000000..60eb05c3a --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/README.rst @@ -0,0 +1,58 @@ +OpenTelemetry Groq Instrumentation Example +============================================ + +This is an example of how to instrument Groq calls when configuring OpenTelemetry SDK and Instrumentations manually. + +When `main.py `_ is run, it exports traces and logs to an OTLP +compatible endpoint. Traces include details such as the model used and the +duration of the chat request. Logs capture the chat request and the generated +response, providing a comprehensive view of the performance and behavior of +your Groq requests. + +Note: `.env <.env>`_ file configures additional environment variables: + +- ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only`` configures Groq instrumentation to capture prompt and completion contents on *span* attributes. +- ``OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK`` (commented out) - uncomment along with ``OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH`` to upload prompts and completions to an ``fsspec``-compatible destination instead of recording them inline. Also uncomment the ``opentelemetry-util-genai[upload]`` line in `requirements.txt `_ and reinstall. + +Setup +----- + +Minimally, update the `.env <.env>`_ file with your ``GROQ_API_KEY``. An +OTLP compatible endpoint should be listening for traces and logs on +http://localhost:4317. If not, update ``OTEL_EXPORTER_OTLP_ENDPOINT`` as well. + +Next, set up a virtual environment like this: + +:: + + python3 -m venv .venv + source .venv/bin/activate + pip install "python-dotenv[cli]" + pip install -r requirements.txt + +Run +--- + +Run the example like this: + +:: + + dotenv run -- python main.py + +You should see a poem generated by Groq while traces and logs export to your +configured observability tool. + +Custom completion hook +---------------------- + +`custom_hook.py `_ is a variant of ``main.py`` that passes a +custom ``CompletionHook`` implementation programmatically via +``GroqInstrumentor().instrument(completion_hook=...)``. The example hook +prints prompts and completions to stdout; real hooks typically forward content +to external storage and record reference URIs on the span/log_record. + +Run it the same way: + +:: + + dotenv run -- python custom_hook.py diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/custom_hook.py new file mode 100644 index 000000000..15d8c880f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/custom_hook.py @@ -0,0 +1,98 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: skip-file +"""Same as main.py, but instruments Groq with a custom CompletionHook +that prints prompts and completions to stdout. + +Run with: dotenv run -- python custom_hook.py +""" + +import os + +from groq import Groq + +from opentelemetry import _logs, metrics, trace +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.instrumentation.genai.groq import GroqInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.util.genai.completion_hook import CompletionHook +from opentelemetry.util.genai.types import ( + InputMessage, + MessagePart, + OutputMessage, + ToolDefinition, +) + + +class PrintCompletionHook(CompletionHook): + """Minimal CompletionHook that prints inputs/outputs to stdout. + + Real hooks typically forward content to external storage (object store, + database, etc.) and record reference URIs on the span/log_record. + """ + + def on_completion( + self, + *, + inputs: list[InputMessage], + outputs: list[OutputMessage], + system_instruction: list[MessagePart], + tool_definitions: list[ToolDefinition] | None = None, + span=None, + log_record=None, + ) -> None: + print(f"[hook] inputs: {inputs}") + print(f"[hook] outputs: {outputs}") + + +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) +) + +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +metrics.set_meter_provider( + MeterProvider( + metric_readers=[ + PeriodicExportingMetricReader(OTLPMetricExporter()), + ] + ) +) + +GroqInstrumentor().instrument(completion_hook=PrintCompletionHook()) + + +def main(): + client = Groq() + chat_completion = client.chat.completions.create( + model=os.getenv("CHAT_MODEL", "llama3-8b-8192"), + messages=[ + { + "role": "user", + "content": "Write a short poem on OpenTelemetry.", + }, + ], + ) + print(chat_completion.choices[0].message.content) + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/main.py b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/main.py new file mode 100644 index 000000000..69f5485c0 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/main.py @@ -0,0 +1,70 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: skip-file +import os + +from groq import Groq + +# NOTE: OpenTelemetry Python Logs API is in beta +from opentelemetry import _logs, metrics, trace +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.instrumentation.genai.groq import GroqInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) +) + +# configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[ + PeriodicExportingMetricReader( + OTLPMetricExporter(), + ), + ] + ) +) + +# instrument Groq +GroqInstrumentor().instrument() + + +def main(): + client = Groq() + chat_completion = client.chat.completions.create( + model=os.getenv("CHAT_MODEL", "llama3-8b-8192"), + messages=[ + { + "role": "user", + "content": "Write a short poem on OpenTelemetry.", + }, + ], + ) + print(chat_completion.choices[0].message.content) + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/requirements.txt b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/requirements.txt new file mode 100644 index 000000000..7ab0859aa --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/manual/requirements.txt @@ -0,0 +1,9 @@ +groq~=0.13.0 + +opentelemetry-sdk~=1.43.0 +opentelemetry-exporter-otlp-proto-grpc~=1.43.0 +opentelemetry-instrumentation-genai-groq~=1.0b0 + +# Uncomment to enable the upload completion hook (pulls in fsspec). +# Required when OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload. +# opentelemetry-util-genai[upload] diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/.env b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/.env new file mode 100644 index 000000000..ff5e01960 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/.env @@ -0,0 +1,29 @@ +# TODO: Set your Groq API key here +GROQ_API_KEY=sk-YOUR_API_KEY + +# Uncomment to use Ollama instead of Groq +# GROQ_BASE_URL=http://localhost:11434/v1 +# GROQ_API_KEY=unused +# CHAT_MODEL=qwen2.5:0.5b + +# Uncomment and change to your OTLP endpoint +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# OTEL_EXPORTER_OTLP_PROTOCOL=grpc + +OTEL_SERVICE_NAME=opentelemetry-python-groq + +# Uncomment if your OTLP endpoint doesn't support logs +# OTEL_LOGS_EXPORTER=console + +# Remove to hide prompt and completion content +# Possible values (case insensitive): +# - `span_only` - record content on span attributes +# - `event_only` - record content on event attributes +# - `span_and_event` - record content on both span and event attributes +# - everything else - don't record content on any signal +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only + +# Uncomment to upload prompts and responses to an fsspec-compatible +# destination instead of or in addition to recording them inline on spans/events. +# OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload +# OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH=/path/to/prompts diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/README.rst b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/README.rst new file mode 100644 index 000000000..4098baa65 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/README.rst @@ -0,0 +1,45 @@ +OpenTelemetry Groq Zero-Code Instrumentation Example +====================================================== + +This is an example of how to instrument Groq calls with zero code changes, +using `opentelemetry-instrument`. + +When `main.py `_ is run, it exports traces and logs to an OTLP +compatible endpoint. Traces include details such as the model used and the +duration of the chat request. Logs capture the chat request and the generated +response, providing a comprehensive view of the performance and behavior of +your Groq requests. + +Note: `.env <.env>`_ file configures additional environment variables: + +- ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_only`` configures Groq instrumentation to capture prompt and completion contents on *span* attributes. +- ``OTEL_LOGS_EXPORTER=otlp`` to specify exporter type. +- ``OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK`` (commented out) - uncomment along with ``OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH`` to upload prompts and completions to an ``fsspec``-compatible destination instead of recording them inline. Also uncomment the ``opentelemetry-util-genai[upload]`` line in `requirements.txt `_ and reinstall. + +Setup +----- + +Minimally, update the `.env <.env>`_ file with your ``GROQ_API_KEY``. An +OTLP compatible endpoint should be listening for traces and logs on +http://localhost:4317. If not, update ``OTEL_EXPORTER_OTLP_ENDPOINT`` as well. + +Next, set up a virtual environment like this: + +:: + + python3 -m venv .venv + source .venv/bin/activate + pip install "python-dotenv[cli]" + pip install -r requirements.txt + +Run +--- + +Run the example like this: + +:: + + dotenv run -- opentelemetry-instrument python main.py + +You should see a poem generated by Groq while traces and logs export to your +configured observability tool. diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/main.py b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/main.py new file mode 100644 index 000000000..c8830e7be --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/main.py @@ -0,0 +1,24 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import os + +from groq import Groq + + +def main(): + client = Groq() + chat_completion = client.chat.completions.create( + model=os.getenv("CHAT_MODEL", "llama-3.1-8b-instant"), + messages=[ + { + "role": "user", + "content": "Write a short poem on OpenTelemetry.", + }, + ], + ) + print(chat_completion.choices[0].message.content) + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/requirements.txt b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/requirements.txt new file mode 100644 index 000000000..6fd5824d6 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/examples/zero-code/requirements.txt @@ -0,0 +1,10 @@ +groq~=0.13.0 + +opentelemetry-sdk~=1.43.0 +opentelemetry-exporter-otlp-proto-grpc~=1.43.0 +opentelemetry-distro~=0.64b0 +opentelemetry-instrumentation-genai-groq~=1.0b0 + +# Uncomment to enable the upload completion hook (pulls in fsspec). +# Required when OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload. +# opentelemetry-util-genai[upload] diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-groq/pyproject.toml new file mode 100644 index 000000000..4e05e2de6 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-genai-groq" +dynamic = ["version"] +description = "OpenTelemetry Official Groq instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-api ~= 1.43", + "opentelemetry-instrumentation >= 0.64b0, <1", + "opentelemetry-semantic-conventions >= 0.64b0, <1", + "opentelemetry-util-genai >= 1.0b0, <2", +] + +[project.optional-dependencies] +instruments = ["groq >= 0.6.0"] + +[project.entry-points.opentelemetry_instrumentor] +groq = "opentelemetry.instrumentation.genai.groq:GroqInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-genai/tree/main/instrumentation/opentelemetry-instrumentation-genai-groq" +Repository = "https://github.com/open-telemetry/opentelemetry-python-genai" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/genai/groq/version.py" + +[tool.hatch.build.targets.sdist] +include = ["/src", "/tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] + +[tool.towncrier] +directory = ".changelog" +filename = "CHANGELOG.md" +start_string = "\n" +template = "../../scripts/changelog_template.j2" +issue_format = "[#{issue}](https://github.com/open-telemetry/opentelemetry-python-genai/pull/{issue})" +wrap = true +issue_pattern = "^(\\d+)" + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/__init__.py new file mode 100644 index 000000000..999da7183 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/__init__.py @@ -0,0 +1,146 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +Groq client instrumentation supporting `groq`_, it can be enabled by +using ``GroqInstrumentor``. + +.. _groq: https://pypi.org/project/groq/ + +Usage +----- + +.. code:: python + + from groq import Groq + from opentelemetry.instrumentation.genai.groq import GroqInstrumentor + + GroqInstrumentor().instrument() + + client = Groq() + response = client.chat.completions.create( + model="llama3-8b-8192", + messages=[ + {"role": "user", "content": "Write a short poem on open telemetry."}, + ], + ) + +Configuration +------------- + +This instrumentation emits telemetry using the latest GenAI semantic +conventions and does not capture prompt or completion content by default. +Behavior is controlled via environment variables: + +- ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` - enable capture of + prompts, completions, tool arguments, and return values. Supported values + are ``span_only``, ``event_only``, and ``span_and_event``. +- ``OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload`` together with + ``OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH=`` - upload + prompts and completions to an ``fsspec``-compatible destination + (local filesystem, ``gs://``, ``s3://``, etc.) and record reference URIs as + ``gen_ai.input.messages.ref`` / ``gen_ai.output.messages.ref`` attributes. + Inline content is not captured unless + ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` is also set. + +See the `opentelemetry-util-genai README +`_ +for the full list of GenAI configuration variables. + +A custom ``CompletionHook`` implementation can also be passed programmatically:: + + GroqInstrumentor().instrument(completion_hook=my_hook) + +When provided, this takes precedence over the hook resolved from +``OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK``. + +API +--- +""" + +from typing import Collection + +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation.genai.groq.package import _instruments +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.util.genai.completion_hook import load_completion_hook +from opentelemetry.util.genai.handler import ( + TelemetryHandler, +) + +from .patch import ( + async_chat_completions_create_v_new, + chat_completions_create_v_new, +) + + +def _is_parse_supported(): + """Check if the parse() method is available on the Completions class.""" + try: + from groq.resources.chat.completions import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + Completions, + ) + + return hasattr(Completions, "parse") + except ImportError: + return False + + +class GroqInstrumentor(BaseInstrumentor): + def __init__(self): + self._parse_supported = False + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + """Enable Groq instrumentation.""" + + tracer_provider = kwargs.get("tracer_provider") + logger_provider = kwargs.get("logger_provider") + meter_provider = kwargs.get("meter_provider") + + handler = TelemetryHandler( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + completion_hook=kwargs.get("completion_hook") + or load_completion_hook(), + ) + + wrap_function_wrapper( + "groq.resources.chat.completions", + "Completions.create", + chat_completions_create_v_new(handler), + ) + + wrap_function_wrapper( + "groq.resources.chat.completions", + "AsyncCompletions.create", + async_chat_completions_create_v_new(handler), + ) + + self._parse_supported = _is_parse_supported() + if self._parse_supported: + wrap_function_wrapper( + "groq.resources.chat.completions", + "Completions.parse", + chat_completions_create_v_new(handler), + ) + + wrap_function_wrapper( + "groq.resources.chat.completions", + "AsyncCompletions.parse", + async_chat_completions_create_v_new(handler), + ) + + def _uninstrument(self, **kwargs): + import groq # pylint: disable=import-outside-toplevel # noqa: PLC0415 + + unwrap(groq.resources.chat.completions.Completions, "create") + unwrap(groq.resources.chat.completions.AsyncCompletions, "create") + if self._parse_supported: + unwrap(groq.resources.chat.completions.Completions, "parse") + unwrap(groq.resources.chat.completions.AsyncCompletions, "parse") diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/chat_buffers.py b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/chat_buffers.py new file mode 100644 index 000000000..c89b4d3bb --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/chat_buffers.py @@ -0,0 +1,52 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + + +class ToolCallBuffer: + def __init__( + self, + index: int, + tool_call_id: str | None, + function_name: str | None, + ) -> None: + self.index: int = index + self.function_name: str | None = function_name + self.tool_call_id: str | None = tool_call_id + self.arguments: list[str] = [] + + def append_arguments(self, arguments: str | None) -> None: + if arguments is not None: + self.arguments.append(arguments) + + +class ChoiceBuffer: + def __init__(self, index: int) -> None: + self.index: int = index + self.finish_reason: str | None = None + self.text_content: list[str] = [] + self.tool_calls_buffers: list[ToolCallBuffer | None] = [] + + def append_text_content(self, content: str) -> None: + self.text_content.append(content) + + def append_tool_call(self, tool_call: Any) -> None: + idx = tool_call.index + for _ in range(len(self.tool_calls_buffers), idx + 1): + self.tool_calls_buffers.append(None) + + function = tool_call.function + buffer = self.tool_calls_buffers[idx] + if buffer is None: + buffer = ToolCallBuffer( + idx, + tool_call.id, + function.name if function else None, + ) + self.tool_calls_buffers[idx] = buffer + + if function: + buffer.append_arguments(function.arguments) diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/chat_wrappers.py b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/chat_wrappers.py new file mode 100644 index 000000000..bd0e193d7 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/chat_wrappers.py @@ -0,0 +1,221 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +from typing import Any, Optional + +from opentelemetry.semconv._incubating.attributes import ( + openai_attributes as OpenAIAttributes, +) +from opentelemetry.util.genai.invocation import InferenceInvocation +from opentelemetry.util.genai.stream import ( + AsyncStreamWrapper, + SyncStreamWrapper, +) +from opentelemetry.util.genai.types import ( + OutputMessage, + Text, + ToolCallRequest, +) + +from .chat_buffers import ChoiceBuffer + + +class _ChatStreamMixin: + """Chat-specific hooks shared by sync and async stream wrappers.""" + + _self_invocation: InferenceInvocation + _self_capture_content: bool + _self_choice_buffers: list[ChoiceBuffer] + _self_response_id: Optional[str] + _self_response_model: Optional[str] + _self_service_tier: Optional[str] + _self_prompt_tokens: Optional[int] + _self_completion_tokens: Optional[int] + + def _set_response_model(self, chunk: Any) -> None: + if self._self_response_model: + return + + if getattr(chunk, "model", None): + self._self_response_model = chunk.model + + def _set_response_id(self, chunk: Any) -> None: + if self._self_response_id: + return + + if getattr(chunk, "id", None): + self._self_response_id = chunk.id + + def _set_response_service_tier(self, chunk: Any) -> None: + if self._self_service_tier: + return + + service_tier = getattr(chunk, "service_tier", None) + if service_tier: + self._self_service_tier = service_tier + + def _build_streaming_response(self, chunk: Any) -> None: + if getattr(chunk, "choices", None) is None: + return + + for choice in chunk.choices: + if not getattr(choice, "delta", None): + continue + + for idx in range(len(self._self_choice_buffers), choice.index + 1): + self._self_choice_buffers.append(ChoiceBuffer(idx)) + + if getattr(choice, "finish_reason", None): + self._self_choice_buffers[ + choice.index + ].finish_reason = choice.finish_reason + + if getattr(choice.delta, "content", None) is not None: + self._self_choice_buffers[choice.index].append_text_content( + choice.delta.content + ) + + if getattr(choice.delta, "tool_calls", None) is not None: + for tool_call in choice.delta.tool_calls: + self._self_choice_buffers[choice.index].append_tool_call( + tool_call + ) + + def _set_usage(self, chunk: Any) -> None: + usage = getattr(chunk, "x_groq", None) + if usage and getattr(usage, "usage", None): + self._self_completion_tokens = getattr( + usage.usage, "completion_tokens", None + ) + self._self_prompt_tokens = getattr( + usage.usage, "prompt_tokens", None + ) + + def _process_chunk(self, chunk: Any) -> None: + self._set_response_id(chunk) + self._set_response_model(chunk) + self._set_response_service_tier(chunk) + self._build_streaming_response(chunk) + self._set_usage(chunk) + + def _set_output_messages(self) -> None: + if not self._self_capture_content: # optimization + return + output_messages = [] + for choice in self._self_choice_buffers: + message = OutputMessage( + role="assistant", + finish_reason=choice.finish_reason or "error", + parts=[], + ) + if choice.text_content: + message.parts.append( + Text(content="".join(choice.text_content)) + ) + if choice.tool_calls_buffers: + tool_calls = [] + for tool_call in filter(None, choice.tool_calls_buffers): + arguments = None + arguments_str = "".join(tool_call.arguments) + if arguments_str: + try: + arguments = json.loads(arguments_str) + except json.JSONDecodeError: + arguments = arguments_str + tool_call_part = ToolCallRequest( + name=tool_call.function_name, + id=tool_call.tool_call_id, + arguments=arguments, + ) + tool_calls.append(tool_call_part) + message.parts.extend(tool_calls) + output_messages.append(message) + + self._self_invocation.output_messages = output_messages + + def _on_stream_end(self) -> None: + self._cleanup() + + def _on_stream_error(self, error: BaseException) -> None: + self._cleanup(error) + + def parse(self) -> _ChatStreamMixin: + """Called when using with_raw_response with stream=True.""" + return self + + def _cleanup(self, error: Optional[BaseException] = None) -> None: + self._self_invocation.response_model_name = self._self_response_model + self._self_invocation.response_id = self._self_response_id + self._self_invocation.input_tokens = self._self_prompt_tokens + self._self_invocation.output_tokens = self._self_completion_tokens + finish_reasons = [ + choice.finish_reason + for choice in self._self_choice_buffers + if choice.finish_reason + ] + if finish_reasons: + self._self_invocation.finish_reasons = finish_reasons + if self._self_service_tier: + self._self_invocation.attributes.update( + { + OpenAIAttributes.OPENAI_RESPONSE_SERVICE_TIER: self._self_service_tier + }, + ) + + self._set_output_messages() + + if error: + self._self_invocation.fail(error) + else: + self._self_invocation.stop() + + +class ChatStreamWrapper( + _ChatStreamMixin, + SyncStreamWrapper, +): + def __init__( + self, + stream: Any, + invocation: InferenceInvocation, + capture_content: bool, + ) -> None: + super().__init__(stream) + self._self_invocation = invocation + self._self_choice_buffers = [] + self._self_capture_content = capture_content + self._self_response_id = None + self._self_response_model = None + self._self_service_tier = None + self._self_prompt_tokens = None + self._self_completion_tokens = None + + +class AsyncChatStreamWrapper( + _ChatStreamMixin, + AsyncStreamWrapper, +): + def __init__( + self, + stream: Any, + invocation: InferenceInvocation, + capture_content: bool, + ) -> None: + super().__init__(stream) + self._self_invocation = invocation + self._self_choice_buffers = [] + self._self_capture_content = capture_content + self._self_response_id = None + self._self_response_model = None + self._self_service_tier = None + self._self_prompt_tokens = None + self._self_completion_tokens = None + + +__all__ = [ + "AsyncChatStreamWrapper", + "ChatStreamWrapper", +] diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/package.py b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/package.py new file mode 100644 index 000000000..150e9e2a9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/package.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +_instruments = ("groq >= 0.6.0",) diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/patch.py b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/patch.py new file mode 100644 index 000000000..980586119 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/patch.py @@ -0,0 +1,130 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + + +import logging + +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.invocation import ( + InferenceInvocation, +) +from opentelemetry.util.genai.types import ( + Error, +) + +from .chat_wrappers import AsyncChatStreamWrapper, ChatStreamWrapper +from .utils import ( + _prepare_output_messages, + create_chat_invocation, + is_streaming, +) + +_logger = logging.getLogger(__name__) + + +def chat_completions_create_v_new( + handler: TelemetryHandler, +): + """Wrap the `create` method of the `ChatCompletion` class to trace it.""" + capture_content = handler.should_capture_content() + + def traced_method(wrapped, instance, args, kwargs): + chat_invocation = create_chat_invocation( + handler, kwargs, instance, capture_content=capture_content + ) + + try: + result = wrapped(*args, **kwargs) + if hasattr(result, "parse"): + # result is of type LegacyAPIResponse, call parse to get the actual response + parsed_result = result.parse() + else: + parsed_result = result + if is_streaming(kwargs): + return ChatStreamWrapper( + parsed_result, chat_invocation, capture_content + ) + + _set_response_properties( + chat_invocation, parsed_result, capture_content + ) + chat_invocation.stop() + return result + except Exception as error: + chat_invocation.fail(Error(type=type(error), message=str(error))) + raise + + return traced_method + + +def async_chat_completions_create_v_new( + handler: TelemetryHandler, +): + """Wrap the `create` method of the `AsyncChatCompletion` class to trace it.""" + capture_content = handler.should_capture_content() + + async def traced_method(wrapped, instance, args, kwargs): + chat_invocation = create_chat_invocation( + handler, kwargs, instance, capture_content=capture_content + ) + + try: + result = await wrapped(*args, **kwargs) + if hasattr(result, "parse"): + # result is of type LegacyAPIResponse, calling parse to get the actual response + parsed_result = result.parse() + else: + parsed_result = result + if is_streaming(kwargs): + return AsyncChatStreamWrapper( + parsed_result, chat_invocation, capture_content + ) + + _set_response_properties( + chat_invocation, parsed_result, capture_content + ) + chat_invocation.stop() + return result + + except Exception as error: + chat_invocation.fail(Error(type=type(error), message=str(error))) + raise + + return traced_method + + +def _set_response_properties( + chat_invocation: InferenceInvocation, result, capture_content: bool +) -> InferenceInvocation: + if getattr(result, "model", None): + chat_invocation.response_model_name = result.model + + if getattr(result, "choices", None): + finish_reasons = [] + for choice in result.choices: + finish_reasons.append(choice.finish_reason or "error") + + chat_invocation.finish_reasons = finish_reasons + + if capture_content: # optimization + chat_invocation.output_messages = _prepare_output_messages( + result.choices + ) + + if getattr(result, "id", None): + chat_invocation.response_id = result.id + + if getattr(result, "usage", None): + chat_invocation.input_tokens = result.usage.prompt_tokens + chat_invocation.output_tokens = result.usage.completion_tokens + elif getattr(result, "x_groq", None): + usage = getattr(result.x_groq, "usage", None) + if usage: + chat_invocation.input_tokens = getattr( + usage, "prompt_tokens", None + ) + chat_invocation.output_tokens = getattr( + usage, "completion_tokens", None + ) + + return chat_invocation diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/utils.py b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/utils.py new file mode 100644 index 000000000..fea7281aa --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/utils.py @@ -0,0 +1,254 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +from typing import Any, Iterable, List, Mapping +from urllib.parse import urlparse + +import groq +from groq import NotGiven +from httpx import URL + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.invocation import ( + InferenceInvocation, +) +from opentelemetry.util.genai.types import ( + FunctionToolDefinition, + InputMessage, + OutputMessage, + Text, + ToolCallRequest, + ToolCallResponse, + ToolDefinition, +) + +_GroqOmit = getattr(groq, "Omit", None) + + +def get_property_value(obj, property_name): + if isinstance(obj, dict): + return obj.get(property_name, None) + + return getattr(obj, property_name, None) + + +def get_server_address_and_port( + client_instance, +) -> tuple[str | None, int | None]: + base_client = getattr(client_instance, "_client", None) + base_url = getattr(base_client, "base_url", None) + if not base_url: + return None, None + address = None + port = None + if isinstance(base_url, URL): + address = base_url.host + port = base_url.port + elif isinstance(base_url, str): + url = urlparse(base_url) + address = url.hostname + port = url.port + + if port == 443: + port = None + + return address, port + + +def is_streaming(kwargs): + return non_numerical_value_is_set(kwargs.get("stream")) + + +def non_numerical_value_is_set(value: bool | str | NotGiven | None): + return bool(value) and value_is_set(value) + + +def value_is_set(value): + if _GroqOmit is not None and isinstance(value, _GroqOmit): + return False + return value is not None and not isinstance(value, NotGiven) + + +def _openai_response_format_to_output_type(response_format_type: str) -> str: + if response_format_type in ("json_object", "json_schema"): + return GenAIAttributes.GenAiOutputTypeValues.JSON.value + return response_format_type + + +def create_chat_invocation( + handler: TelemetryHandler, + kwargs, + client_instance, + capture_content: bool, +) -> InferenceInvocation: + # pylint: disable=too-many-branches + + address, port = get_server_address_and_port(client_instance) + invocation = handler.inference( + "groq", + request_model=kwargs.get("model", ""), + server_address=address if address else None, + server_port=port if port else None, + ) + invocation.temperature = get_value(kwargs.get("temperature")) + invocation.top_p = get_value(kwargs.get("p") or kwargs.get("top_p")) + invocation.max_tokens = get_value(kwargs.get("max_tokens")) + invocation.presence_penalty = get_value(kwargs.get("presence_penalty")) + invocation.frequency_penalty = get_value(kwargs.get("frequency_penalty")) + invocation.seed = get_value(kwargs.get("seed")) + if (stop_sequences := get_value(kwargs.get("stop"))) is not None: + if isinstance(stop_sequences, str): + stop_sequences = [stop_sequences] + invocation.stop_sequences = stop_sequences + + if (choice_count := get_value(kwargs.get("n"))) is not None: + # Only add non default, meaningful values + if isinstance(choice_count, int) and choice_count != 1: + invocation.request_choice_count = choice_count + + if ( + response_format := get_value(kwargs.get("response_format")) + ) is not None: + if isinstance(response_format, type): + invocation.attributes[GenAIAttributes.GEN_AI_OUTPUT_TYPE] = ( + GenAIAttributes.GenAiOutputTypeValues.JSON.value + ) + elif isinstance(response_format, Mapping): + if ( + response_format_type := get_value(response_format.get("type")) + ) is not None: + invocation.attributes[GenAIAttributes.GEN_AI_OUTPUT_TYPE] = ( + _openai_response_format_to_output_type( + response_format_type + ) + ) + elif isinstance(response_format, str): + invocation.attributes[GenAIAttributes.GEN_AI_OUTPUT_TYPE] = ( + _openai_response_format_to_output_type(response_format) + ) + + if capture_content: # optimization + invocation.input_messages = _prepare_input_messages( + kwargs.get("messages", []) + ) + invocation.tool_definitions = _prepare_tool_definitions( + kwargs.get("tools") + ) + return invocation + + +def get_value(v: Any): + if value_is_set(v): + return v + return None + + +def _is_text_part(content: Any) -> bool: + return isinstance(content, str) or ( + isinstance(content, Iterable) + and all(isinstance(part, str) for part in content) + ) + + +def _prepare_input_messages(messages) -> List[InputMessage]: + chat_messages = [] + for message in messages: + role = get_property_value(message, "role") + chat_message = InputMessage(role=str(role), parts=[]) + chat_messages.append(chat_message) + + content = get_property_value(message, "content") + + if role == "assistant": + tool_calls = get_property_value(message, "tool_calls") + if tool_calls: + chat_message.parts += extract_tool_calls_new(tool_calls) + if _is_text_part(content): + chat_message.parts.append(Text(content=str(content))) + + elif role == "tool": + tool_call_id = get_property_value(message, "tool_call_id") + chat_message.parts.append( + ToolCallResponse(id=tool_call_id, response=content) + ) + + else: + # system, developer, user, fallback + if _is_text_part(content): + chat_message.parts.append(Text(content=str(content))) + return chat_messages + + +def extract_tool_calls_new(tool_calls) -> list[ToolCallRequest]: + parts = [] + for tool_call in tool_calls: + call_id = get_property_value(tool_call, "id") + + func_name = "" + arguments = None + func = get_property_value(tool_call, "function") + if func: + func_name = get_property_value(func, "name") or "" + arguments_str = get_property_value(func, "arguments") + if arguments_str: + try: + arguments = json.loads(arguments_str) + except json.JSONDecodeError: + arguments = arguments_str + + parts.append( + ToolCallRequest(id=call_id, name=func_name, arguments=arguments) + ) + return parts + + +def _prepare_tool_definitions(tools) -> list[ToolDefinition] | None: + if not tools: + return None + + definitions: list[ToolDefinition] = [] + for tool in tools: + tool_type = get_property_value(tool, "type") + if tool_type == "function": + func = get_property_value(tool, "function") + if func: + definitions.append( + FunctionToolDefinition( + name=get_property_value(func, "name") or "", + description=get_property_value(func, "description"), + parameters=get_property_value(func, "parameters"), + ) + ) + return definitions + + +def _prepare_output_messages(choices) -> List[OutputMessage]: + output_messages = [] + for choice in choices: + if choice.message: + parts = [] + tool_calls = get_property_value(choice.message, "tool_calls") + if tool_calls: + parts += extract_tool_calls_new(tool_calls) + content = get_property_value(choice.message, "content") + if _is_text_part(content): + parts.append(Text(content=str(content))) + + message = OutputMessage( + finish_reason=choice.finish_reason or "error", + role=( + choice.message.role + if choice.message and choice.message.role + else "" + ), + parts=parts, + ) + output_messages.append(message) + + return output_messages diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/version.py b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/version.py new file mode 100644 index 000000000..d302c33ff --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/src/opentelemetry/instrumentation/genai/groq/version.py @@ -0,0 +1,3 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 +__version__ = "1.0b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/__init__.py new file mode 100644 index 000000000..e57cf4aba --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/inference_conformance.yaml b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/inference_conformance.yaml new file mode 100644 index 000000000..8b49345b1 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/inference_conformance.yaml @@ -0,0 +1,48 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Say this is a test"}],"model":"llama3-8b-8192","stream":false}' + headers: + authorization: + - Bearer test_groq_api_key + content-type: + - application/json + method: POST + uri: https://api.groq.com/openai/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-12345", + "object": "chat.completion", + "created": 1731368630, + "model": "llama3-8b-8192", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "This is a test." + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 14, + "total_tokens": 26 + }, + "x_groq": { + "usage": { + "prompt_tokens": 12, + "completion_tokens": 14 + } + } + } + headers: + Content-Type: + - application/json + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_async_chat_completions_basic.yaml b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_async_chat_completions_basic.yaml new file mode 100644 index 000000000..d2ce4302b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_async_chat_completions_basic.yaml @@ -0,0 +1,21 @@ +# TODO: this is generated by AI, re-record +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Tell me a joke"}],"model":"llama3-8b-8192"}' + headers: + accept: + - application/json + content-type: + - application/json + method: POST + uri: https://api.groq.com/openai/v1/chat/completions + response: + body: + string: '{"id":"chatcmpl-test","choices":[{"finish_reason":"stop","index":0,"message":{"content":"A joke!","role":"assistant"}}],"created":123,"model":"llama3-8b-8192","object":"chat.completion"}' + headers: + content-type: + - application/json + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_async_chat_completions_streaming.yaml b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_async_chat_completions_streaming.yaml new file mode 100644 index 000000000..fef748b35 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_async_chat_completions_streaming.yaml @@ -0,0 +1,27 @@ +# TODO: this is generated by AI, re-record +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Tell me a joke"}],"model":"llama3-8b-8192","stream":true}' + headers: + accept: + - application/json + content-type: + - application/json + method: POST + uri: https://api.groq.com/openai/v1/chat/completions + response: + body: + string: 'data: {"id":"chatcmpl-test","choices":[{"delta":{"content":"A"},"index":0}],"created":123,"model":"llama3-8b-8192","object":"chat.completion.chunk"} + +data: {"id":"chatcmpl-test","choices":[{"delta":{},"finish_reason":"stop","index":0}],"created":123,"model":"llama3-8b-8192","object":"chat.completion.chunk"} + +data: [DONE] + +' + headers: + content-type: + - text/event-stream + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_basic.yaml b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_basic.yaml new file mode 100644 index 000000000..f03abb0ae --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_basic.yaml @@ -0,0 +1,48 @@ +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Tell me a joke"}],"model":"llama3-8b-8192"}' + headers: + authorization: + - Bearer test_groq_api_key + content-type: + - application/json + method: POST + uri: https://api.groq.com/openai/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-12345", + "object": "chat.completion", + "created": 1731368630, + "model": "llama3-8b-8192", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Why did the chicken cross the road? To get to the other side." + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 14, + "total_tokens": 26 + }, + "x_groq": { + "usage": { + "prompt_tokens": 12, + "completion_tokens": 14 + } + } + } + headers: + Content-Type: + - application/json + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_caller_side_error.yaml b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_caller_side_error.yaml new file mode 100644 index 000000000..1f9bba514 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_caller_side_error.yaml @@ -0,0 +1,27 @@ +# TODO: this is generated by AI, re-record +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Tell me a long joke"}],"model":"llama3-8b-8192","stream":true}' + headers: + accept: + - application/json + content-type: + - application/json + method: POST + uri: https://api.groq.com/openai/v1/chat/completions + response: + body: + string: 'data: {"id":"chatcmpl-test","choices":[{"delta":{"content":"A"},"index":0}],"created":123,"model":"llama3-8b-8192","object":"chat.completion.chunk"} + +data: {"id":"chatcmpl-test","choices":[{"delta":{"content":" long"},"index":0}],"created":123,"model":"llama3-8b-8192","object":"chat.completion.chunk"} + +data: [DONE] + +' + headers: + content-type: + - text/event-stream + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_provider_error.yaml b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_provider_error.yaml new file mode 100644 index 000000000..b0b582c1c --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_provider_error.yaml @@ -0,0 +1,93 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "role": "user", + "content": "Tell me a joke" + } + ], + "model": "non-existent-model" + } + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + authorization: + - Bearer test_groq_api_key + connection: + - keep-alive + content-length: + - '86' + content-type: + - application/json + host: + - api.groq.com + user-agent: + - Groq/Python 1.6.0 + x-stainless-arch: + - other:amd64 + x-stainless-async: + - 'false' + x-stainless-lang: + - python + x-stainless-os: + - Windows + x-stainless-package-version: + - 1.6.0 + x-stainless-read-timeout: + - '60' + x-stainless-retry-count: + - '0' + x-stainless-runtime: + - CPython + x-stainless-runtime-version: + - 3.12.13 + method: POST + uri: https://api.groq.com/openai/v1/chat/completions + response: + body: + string: |- + { + "error": { + "message": "Invalid API Key", + "type": "invalid_request_error", + "code": "invalid_api_key" + } + } + headers: + alt-svc: + - h3=":443"; ma=86400 + cache-control: + - private, max-age=0, no-store, no-cache, must-revalidate + cf-cache-status: + - DYNAMIC + cf-ray: + - a2471c7b281cb1c5-BOM + connection: + - keep-alive + content-length: + - '96' + content-type: + - application/json + date: + - Sat, 01 Aug 2026 19:04:54 GMT + server: + - cloudflare + set-cookie: test_set_cookie + strict-transport-security: + - max-age=15552000 + vary: + - Origin + via: + - 1.1 google + x-groq-region: + - bom + x-request-id: + - req_01kyzbe28dfpt959zxf99vxag3 + status: + code: 401 + message: Unauthorized +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_stream_side_error.yaml b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_stream_side_error.yaml new file mode 100644 index 000000000..1f9bba514 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_stream_side_error.yaml @@ -0,0 +1,27 @@ +# TODO: this is generated by AI, re-record +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Tell me a long joke"}],"model":"llama3-8b-8192","stream":true}' + headers: + accept: + - application/json + content-type: + - application/json + method: POST + uri: https://api.groq.com/openai/v1/chat/completions + response: + body: + string: 'data: {"id":"chatcmpl-test","choices":[{"delta":{"content":"A"},"index":0}],"created":123,"model":"llama3-8b-8192","object":"chat.completion.chunk"} + +data: {"id":"chatcmpl-test","choices":[{"delta":{"content":" long"},"index":0}],"created":123,"model":"llama3-8b-8192","object":"chat.completion.chunk"} + +data: [DONE] + +' + headers: + content-type: + - text/event-stream + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_streaming.yaml b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_streaming.yaml new file mode 100644 index 000000000..fef748b35 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/cassettes/test_chat_completions_streaming.yaml @@ -0,0 +1,27 @@ +# TODO: this is generated by AI, re-record +interactions: +- request: + body: '{"messages":[{"role":"user","content":"Tell me a joke"}],"model":"llama3-8b-8192","stream":true}' + headers: + accept: + - application/json + content-type: + - application/json + method: POST + uri: https://api.groq.com/openai/v1/chat/completions + response: + body: + string: 'data: {"id":"chatcmpl-test","choices":[{"delta":{"content":"A"},"index":0}],"created":123,"model":"llama3-8b-8192","object":"chat.completion.chunk"} + +data: {"id":"chatcmpl-test","choices":[{"delta":{},"finish_reason":"stop","index":0}],"created":123,"model":"llama3-8b-8192","object":"chat.completion.chunk"} + +data: [DONE] + +' + headers: + content-type: + - text/event-stream + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conformance/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conformance/__init__.py new file mode 100644 index 000000000..e57cf4aba --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conformance/__init__.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conformance/inference.py b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conformance/inference.py new file mode 100644 index 000000000..7cf846962 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conformance/inference.py @@ -0,0 +1,49 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: groq chat completion (inference).""" + +from __future__ import annotations + +from typing import Any + +from groq import Groq + +from opentelemetry.instrumentation.genai.groq import GroqInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + + +class InferenceScenario(Scenario): + expected_spans = {"chat": 1} + expected_metrics = ( + "gen_ai.client.operation.duration", + "gen_ai.client.token.usage", + ) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + with instrument( + GroqInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + with vcr.use_cassette("inference_conformance.yaml"): + Groq().chat.completions.create( + messages=[ + {"role": "user", "content": "Say this is a test"} + ], + model="llama3-8b-8192", + stream=False, + ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conftest.py new file mode 100644 index 000000000..1b2dcd627 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/conftest.py @@ -0,0 +1,128 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests configuration module.""" + +import os + +import pytest +from groq import AsyncGroq, Groq + +from opentelemetry.instrumentation.genai.groq import GroqInstrumentor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.sampling import ALWAYS_OFF +from opentelemetry.test_util_genai.instrumentor import instrument + +pytest_plugins = [ + "opentelemetry.test_util_genai.fixtures", + "opentelemetry.test_util_genai.vcr", +] + + +@pytest.fixture(autouse=True) +def environment(): + if not os.getenv("GROQ_API_KEY"): + os.environ["GROQ_API_KEY"] = "test_groq_api_key" + + +@pytest.fixture +def groq_client(): + return Groq() + + +@pytest.fixture +def async_groq_client(): + return AsyncGroq() + + +@pytest.fixture(scope="module") +def vcr_config(): + from opentelemetry.test_util_genai.vcr import ( # noqa: PLC0415 + scrub_response_headers_overwrite, + ) + + return { + "filter_headers": [ + ("cookie", "test_cookie"), + ("authorization", "Bearer test_groq_api_key"), + ], + "decode_compressed_response": True, + "before_record_response": scrub_response_headers_overwrite( + { + "Set-Cookie": "test_set_cookie", + } + ), + } + + +@pytest.fixture( + scope="function", + params=[(True, "span_only")], + name="content_mode", +) +def fixture_content_mode(request): + # returns tuple: (latest_experimental_enabled: bool, content_mode: str) + # we don't test (True, "event_only"), (True, "span_and_event") because it's util's + # responsibility. + return request.param + + +@pytest.fixture(scope="function") +def instrument_no_content( + tracer_provider, + logger_provider, + meter_provider, + content_mode, +): + with instrument( + GroqInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) as instrumentor: + yield instrumentor + + +@pytest.fixture(scope="function") +def instrument_with_content( + tracer_provider, logger_provider, meter_provider, content_mode +): + _, content_mode_value = content_mode + with instrument( + GroqInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture=content_mode_value, + ) as instrumentor: + yield instrumentor + + +@pytest.fixture(scope="function") +def instrument_with_content_unsampled( + span_exporter, logger_provider, meter_provider, content_mode +): + _, content_mode_value = content_mode + tracer_provider = TracerProvider(sampler=ALWAYS_OFF) + tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + with instrument( + GroqInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture=content_mode_value, + ) as instrumentor: + yield instrumentor + + +@pytest.fixture(scope="function") +def instrument_event_only(tracer_provider, logger_provider, meter_provider): + with instrument( + GroqInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="event_only", + ) as instrumentor: + yield instrumentor diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.latest.txt b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.latest.txt new file mode 100644 index 000000000..6151caf32 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.latest.txt @@ -0,0 +1,15 @@ +# 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. + +groq diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.oldest.txt new file mode 100644 index 000000000..2c12cb100 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.oldest.txt @@ -0,0 +1,24 @@ +# 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. + +# Oldest test-only dependency pins. +# +# The package's own declared deps (groq via the instruments extra, opentelemetry-api, +# opentelemetry-instrumentation, opentelemetry-semantic-conventions, opentelemetry-util-genai) +# are resolved to their pyproject.toml floors by UV_RESOLUTION=lowest-direct on the oldest tox +# factor, so they are NOT pinned here — pyproject.toml is the single source of truth. The +# OpenTelemetry SDK and test utilities come transitively from opentelemetry-test-util-genai, which +# every oldest env installs. Pin here only test-only deps that nothing else already provides. + +httpx==0.27.2 \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/test_chat_completions.py b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/test_chat_completions.py new file mode 100644 index 000000000..4afbdc890 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/test_chat_completions.py @@ -0,0 +1,197 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from unittest import mock + +import pytest +from groq import AsyncGroq, Groq + +from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import ( + GEN_AI_OPERATION_NAME, + GEN_AI_REQUEST_MODEL, + GEN_AI_RESPONSE_MODEL, + GEN_AI_SYSTEM, + GenAiOperationNameValues, +) +from opentelemetry.trace import StatusCode + + +@pytest.mark.vcr +def test_chat_completions_basic( + instrument_no_content, + span_exporter, + vcr, + groq_client: Groq, +): + with vcr.use_cassette("test_chat_completions_basic.yaml"): + response = groq_client.chat.completions.create( + model="llama3-8b-8192", + messages=[ + {"role": "user", "content": "Tell me a joke"}, + ], + ) + + assert response is not None + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + + assert ( + span.attributes.get(GEN_AI_SYSTEM) == "groq" + or span.attributes.get("gen_ai.provider.name") == "groq" + ) + assert ( + span.attributes.get(GEN_AI_OPERATION_NAME) + == GenAiOperationNameValues.CHAT.value + ) + assert span.attributes[GEN_AI_REQUEST_MODEL] == "llama3-8b-8192" + assert GEN_AI_RESPONSE_MODEL in span.attributes + + +@pytest.mark.vcr +@pytest.mark.asyncio +async def test_async_chat_completions_basic( + instrument_no_content, + span_exporter, + vcr, + async_groq_client: AsyncGroq, +): + with vcr.use_cassette("test_async_chat_completions_basic.yaml"): + response = await async_groq_client.chat.completions.create( + model="llama3-8b-8192", + messages=[{"role": "user", "content": "Tell me a joke"}], + ) + + assert response is not None + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert ( + span.attributes.get(GEN_AI_SYSTEM) == "groq" + or span.attributes.get("gen_ai.provider.name") == "groq" + ) + assert ( + span.attributes.get(GEN_AI_OPERATION_NAME) + == GenAiOperationNameValues.CHAT.value + ) + assert span.attributes[GEN_AI_REQUEST_MODEL] == "llama3-8b-8192" + + +@pytest.mark.vcr +def test_chat_completions_streaming( + instrument_no_content, + span_exporter, + vcr, + groq_client: Groq, +): + with vcr.use_cassette("test_chat_completions_streaming.yaml"): + response = groq_client.chat.completions.create( + model="llama3-8b-8192", + messages=[{"role": "user", "content": "Tell me a joke"}], + stream=True, + ) + for _ in response: + pass + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert ( + span.attributes.get(GEN_AI_SYSTEM) == "groq" + or span.attributes.get("gen_ai.provider.name") == "groq" + ) + + +@pytest.mark.vcr +@pytest.mark.asyncio +async def test_async_chat_completions_streaming( + instrument_no_content, + span_exporter, + vcr, + async_groq_client: AsyncGroq, +): + with vcr.use_cassette("test_async_chat_completions_streaming.yaml"): + response = await async_groq_client.chat.completions.create( + model="llama3-8b-8192", + messages=[{"role": "user", "content": "Tell me a joke"}], + stream=True, + ) + async for _ in response: + pass + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + + +@pytest.mark.vcr +def test_chat_completions_provider_error( + instrument_no_content, + span_exporter, + vcr, + groq_client: Groq, +): + with vcr.use_cassette("test_chat_completions_provider_error.yaml"): + with pytest.raises(Exception): + groq_client.chat.completions.create( + model="non-existent-model", + messages=[{"role": "user", "content": "Tell me a joke"}], + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert "error.type" in span.attributes + + +@pytest.mark.vcr +def test_chat_completions_caller_side_error_inside_stream( + instrument_no_content, + span_exporter, + vcr, + groq_client: Groq, +): + with vcr.use_cassette("test_chat_completions_caller_side_error.yaml"): + with pytest.raises(ValueError, match="Caller error"): + response = groq_client.chat.completions.create( + model="llama3-8b-8192", + messages=[{"role": "user", "content": "Tell me a long joke"}], + stream=True, + ) + with response: + raise ValueError("Caller error") + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert span.attributes.get("error.type") == "ValueError" + + +@pytest.mark.vcr +def test_chat_completions_stream_side_error_mid_iteration( + instrument_no_content, + span_exporter, + vcr, + groq_client: Groq, +): + with vcr.use_cassette("test_chat_completions_stream_side_error.yaml"): + response = groq_client.chat.completions.create( + model="llama3-8b-8192", + messages=[{"role": "user", "content": "Tell me a long joke"}], + stream=True, + ) + + iterator_mock = mock.MagicMock() + iterator_mock.__next__.side_effect = ConnectionError("Stream dropped") + response._self_iterator = iterator_mock + + with pytest.raises(ConnectionError, match="Stream dropped"): + for _ in response: + pass + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code == StatusCode.ERROR + assert span.attributes.get("error.type") == "ConnectionError" diff --git a/instrumentation/opentelemetry-instrumentation-genai-groq/tests/test_conformance.py b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/test_conformance.py new file mode 100644 index 000000000..c84b975b9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-groq/tests/test_conformance.py @@ -0,0 +1,34 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Per-scenario conformance tests for groq.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +# Skip collection when weaver_live_check isn't installed (non-conformance envs). +pytest.importorskip("opentelemetry.test.weaver_live_check") + +from opentelemetry.test.weaver_live_check import WeaverLiveCheck # noqa: E402 +from opentelemetry.test_util_genai.conformance import ( # noqa: E402 + Scenario, + run_conformance, +) + +from .conformance.inference import InferenceScenario + + +@pytest.mark.parametrize( + "scenario", + [ + InferenceScenario(), + ], + ids=lambda s: type(s).__name__, +) +def test_conformance( + scenario: Scenario, vcr: Any, weaver_live_check: WeaverLiveCheck +) -> None: + run_conformance(scenario, vcr=vcr, weaver=weaver_live_check) diff --git a/pyproject.toml b/pyproject.toml index cbb921195..76666be4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ include = [ "instrumentation/opentelemetry-instrumentation-genai-agno", "instrumentation/opentelemetry-instrumentation-genai-anthropic", "instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk", + "instrumentation/opentelemetry-instrumentation-genai-langchain", "instrumentation/opentelemetry-instrumentation-genai-llama-index", "instrumentation/opentelemetry-instrumentation-genai-openai-agents", @@ -118,6 +119,7 @@ exclude = [ "instrumentation/opentelemetry-instrumentation-genai-anthropic/examples/**/*.py", "instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests/**/*.py", "instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/examples/**/*.py", + "instrumentation/opentelemetry-instrumentation-genai-langchain/tests/**/*.py", "instrumentation/opentelemetry-instrumentation-genai-langchain/examples/**/*.py", "instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/**/*.py", diff --git a/pytest.ini b/pytest.ini index 886af1c76..bf1a6c984 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,3 +3,4 @@ addopts = -rfE -v log_cli = true log_cli_level = warning +asyncio_default_fixture_loop_scope = function diff --git a/screenshots/photo_2026-07-27_03-17-43.jpg b/screenshots/photo_2026-07-27_03-17-43.jpg new file mode 100644 index 000000000..d0548e5ee Binary files /dev/null and b/screenshots/photo_2026-07-27_03-17-43.jpg differ diff --git a/screenshots/photo_2026-07-27_03-17-48.jpg b/screenshots/photo_2026-07-27_03-17-48.jpg new file mode 100644 index 000000000..3db4636cc Binary files /dev/null and b/screenshots/photo_2026-07-27_03-17-48.jpg differ diff --git a/screenshots/photo_2026-07-27_03-17-55.jpg b/screenshots/photo_2026-07-27_03-17-55.jpg new file mode 100644 index 000000000..75cf32270 Binary files /dev/null and b/screenshots/photo_2026-07-27_03-17-55.jpg differ diff --git a/scripts/check_license_header.py b/scripts/check_license_header.py index 500476344..286802c68 100644 --- a/scripts/check_license_header.py +++ b/scripts/check_license_header.py @@ -16,6 +16,7 @@ "target", ".tox", ".venv", + "test_env", "__pycache__", "node_modules", ) diff --git a/tox.ini b/tox.ini index b614d3b57..86f8c17ed 100644 --- a/tox.ini +++ b/tox.ini @@ -43,6 +43,12 @@ envlist = py314-test-instrumentation-genai-anthropic-conformance lint-instrumentation-genai-anthropic + ; instrumentation-genai-groq + py3{10,11,12,13,14}-test-instrumentation-genai-groq-latest + py310-test-instrumentation-genai-groq-oldest + py314-test-instrumentation-genai-groq-conformance + lint-instrumentation-genai-groq + ; instrumentation-genai-claude-agent-sdk py3{10,11,12,13}-test-instrumentation-genai-claude-agent-sdk-latest py310-test-instrumentation-genai-claude-agent-sdk-oldest @@ -145,6 +151,17 @@ deps = anthropic-conformance: {[testenv]pytest_deps} anthropic-conformance: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.latest.txt + groq-oldest: {[testenv]pytest_deps} + groq-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq[instruments] + groq-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.oldest.txt + groq-latest: {[testenv]test_deps} + groq-latest: {[testenv]pytest_deps} + groq-latest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq[instruments] + groq-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.latest.txt + groq-conformance: {[testenv]pytest_deps} + groq-conformance: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq[instruments] + groq-conformance: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq/tests/requirements.latest.txt + claude-agent-sdk-oldest: {[testenv]pytest_deps} claude-agent-sdk-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk[instruments] claude-agent-sdk-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests/requirements.oldest.txt @@ -221,6 +238,10 @@ commands = test-instrumentation-genai-anthropic-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-genai-anthropic: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-anthropic" + test-instrumentation-genai-groq-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq/tests {posargs} + test-instrumentation-genai-groq-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq/tests/test_conformance.py --vcr-record=none {posargs} + lint-instrumentation-genai-groq: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-groq" + test-instrumentation-genai-claude-agent-sdk: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests --vcr-record=none {posargs} lint-instrumentation-genai-claude-agent-sdk: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-claude-agent-sdk" @@ -296,11 +317,11 @@ commands = [testenv:shellcheck] -commands_pre = - sh -c "sudo apt update -y && sudo apt install --assume-yes shellcheck" +# commands_pre = +# sh -c "sudo apt update -y && sudo apt install --assume-yes shellcheck" commands = - sh -c "find {toxinidir} -name \*.sh | xargs shellcheck --severity=warning" + sh -c "find {toxinidir} -type f -name \*.sh -not -path '*/.tox/*' -not -path '*/.venv/*' -not -path '*/test_env/*' | xargs --no-run-if-empty shellcheck --severity=warning" [testenv:{precommit,ruff}] basepython: python3 @@ -319,6 +340,7 @@ deps = -e {toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai[instruments] -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno[instruments] -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic[instruments] + -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-groq[instruments] -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain[instruments] -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-llama-index[instruments] -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk[instruments] diff --git a/uv.lock b/uv.lock index 13c7208d8..c3c5fda67 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ members = [ "opentelemetry-instrumentation-genai-agno", "opentelemetry-instrumentation-genai-anthropic", "opentelemetry-instrumentation-genai-claude-agent-sdk", + "opentelemetry-instrumentation-genai-groq", "opentelemetry-instrumentation-genai-langchain", "opentelemetry-instrumentation-genai-llama-index", "opentelemetry-instrumentation-genai-openai", @@ -1054,6 +1055,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, ] +[[package]] +name = "groq" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/3e/687064918e861082cb02165872bbda31269126a108a78794dae86ddadae2/groq-1.6.0.tar.gz", hash = "sha256:c4237ecf0053ba85fd926bb31cb4b178996310474b4618867994e3e40d8e3c02", size = 158424, upload-time = "2026-07-24T17:25:49.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/40/164338d796cf41ea5930a42023e986d8c993720bed670d5aa143e5854873/groq-1.6.0-py3-none-any.whl", hash = "sha256:fa16db582455db324adcff1b5908519474736872e28cd5c8fa2b8bef6860e12a", size = 143693, upload-time = "2026-07-24T17:25:48.2Z" }, +] + [[package]] name = "grpcio" version = "1.78.0" @@ -2303,6 +2321,31 @@ requires-dist = [ ] provides-extras = ["instruments"] +[[package]] +name = "opentelemetry-instrumentation-genai-groq" +source = { editable = "instrumentation/opentelemetry-instrumentation-genai-groq" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-genai" }, +] + +[package.optional-dependencies] +instruments = [ + { name = "groq" }, +] + +[package.metadata] +requires-dist = [ + { name = "groq", marker = "extra == 'instruments'", specifier = ">=0.6.0" }, + { name = "opentelemetry-api", specifier = "~=1.43" }, + { name = "opentelemetry-instrumentation", specifier = ">=0.64b0,<1" }, + { name = "opentelemetry-semantic-conventions", specifier = ">=0.64b0,<1" }, + { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-genai-langchain" source = { editable = "instrumentation/opentelemetry-instrumentation-genai-langchain" }