From 01c9e11f40deb1e233093d84157f4ade71abee5a Mon Sep 17 00:00:00 2001 From: Prathamesh Sonpatki Date: Mon, 20 Apr 2026 10:38:00 +0530 Subject: [PATCH 1/3] feat: install() one-call setup helper (v1.3.0) Collapses the usual six-line setup (TracerProvider + Last9SpanProcessor + LoggerProvider + Last9LogToSpanProcessor + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env + OpenAIInstrumentor.instrument()) into a single `install()` call. The helper: - creates the providers if the caller didn't pass any, and optionally registers them as OTel globals (set_global=True by default) - wires Last9SpanProcessor + Last9LogToSpanProcessor together so span and log pipelines share state - sets OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT unless the caller already set it - best-effort calls OpenAIInstrumentor().instrument(logger_provider=...) if opentelemetry-instrumentation-openai-v2 is importable - returns an InstallHandle dataclass exposing both providers, both processors, and a shutdown() convenience Also forwards cost-tracking kwargs (custom_pricing, enable_cost_tracking, workflow_tracker) through to Last9SpanProcessor so install() is a full replacement for the manual wiring. Bumps version to 1.3.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 16 +++++ README.md | 27 +++++++- last9_genai/__init__.py | 8 ++- last9_genai/install.py | 138 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- setup.py | 2 +- tests/test_install.py | 91 ++++++++++++++++++++++++++ uv.lock | 2 +- 8 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 last9_genai/install.py create mode 100644 tests/test_install.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ee644a0..369237e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] - 2026-04-20 + +### Added +- **`install()`** one-call setup helper that wires `TracerProvider`, + `LoggerProvider`, `Last9SpanProcessor`, `Last9LogToSpanProcessor`, the + `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` env var, and + `OpenAIInstrumentor().instrument(logger_provider=...)` (when + `opentelemetry-instrumentation-openai-v2` is installed). Collapses the + typical six-line boilerplate to a single call. +- `install()` returns an `InstallHandle` dataclass so callers can reach the + provider (to attach exporters) and call `shutdown()`. +- Accepts caller-provided `tracer_provider` / `logger_provider`, forwards + cost-tracking kwargs (`custom_pricing`, `enable_cost_tracking`, …) through + to `Last9SpanProcessor`, and can be opted out of instrumentation / global + registration. + ## [1.2.0] - 2026-04-20 ### Added diff --git a/README.md b/README.md index 3951c55..af8afa4 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,29 @@ payloads onto the currently active span: - indexed `gen_ai.prompt.{i}.*` / `gen_ai.completion.{i}.*` (AgentOps / Traceloop compatible) +**Recommended (one call):** + +```python +from last9_genai import install + +handle = install() + +# add your OTLP exporter to the returned provider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +handle.tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) +``` + +`install()` creates the TracerProvider + LoggerProvider if they don't exist +(pass your own via `tracer_provider=` / `logger_provider=`), wires +`Last9SpanProcessor` and `Last9LogToSpanProcessor` together, sets +`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`, and calls +`OpenAIInstrumentor().instrument(logger_provider=...)` if openai-v2 is +installed. Pass `instrument_openai=False` if you want to wire instrumentation +yourself. + +**Manual wiring (same result, when you need more control):** + ```python from opentelemetry import trace, _logs from opentelemetry.sdk.trace import TracerProvider @@ -368,8 +391,8 @@ os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true" OpenAIInstrumentor().instrument(logger_provider=logger_provider) ``` -After this, every LLM call instrumented by `openai-v2` has its full prompt -and completion content available on the span. +Either path: every LLM call instrumented by `openai-v2` now has its full +prompt and completion content available on the span. > **Python 3.14 users**: pin `wrapt<2`. `opentelemetry-instrumentation-openai-v2` > 2.3b0 calls `wrap_function_wrapper(module=..., name=..., wrapper=...)` and diff --git a/last9_genai/__init__.py b/last9_genai/__init__.py index b2073b6..29a75a0 100644 --- a/last9_genai/__init__.py +++ b/last9_genai/__init__.py @@ -34,7 +34,7 @@ For more information, see: https://github.com/last9/python-ai-sdk """ -__version__ = "1.2.0" +__version__ = "1.3.0" __author__ = "Last9 Inc." __license__ = "MIT" @@ -70,6 +70,9 @@ # Import log-to-span bridge for GenAI log events from last9_genai.log_processor import Last9LogToSpanProcessor +# One-call setup helper +from last9_genai.install import install, InstallHandle + # Import decorators (auto-tracking) from last9_genai.decorators import observe @@ -104,6 +107,9 @@ "Last9SpanProcessor", # Log-to-span bridge "Last9LogToSpanProcessor", + # One-call setup helper + "install", + "InstallHandle", # Decorators (NEW) "observe", ] diff --git a/last9_genai/install.py b/last9_genai/install.py new file mode 100644 index 0000000..a89e78b --- /dev/null +++ b/last9_genai/install.py @@ -0,0 +1,138 @@ +""" +One-call setup helper that wires up the full Last9 GenAI observability stack. + +Collapses the six-line boilerplate (TracerProvider + Last9SpanProcessor + +LoggerProvider + Last9LogToSpanProcessor + capture-content env var + +OpenAI instrumentation) into a single ``install()`` call. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + +from opentelemetry import _logs, trace +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.trace import TracerProvider + +from .log_processor import Last9LogToSpanProcessor +from .processor import Last9SpanProcessor + + +@dataclass +class InstallHandle: + """Handles returned by :func:`install` so callers can flush or tear down.""" + + tracer_provider: TracerProvider + logger_provider: LoggerProvider + span_processor: Last9SpanProcessor + log_processor: Last9LogToSpanProcessor + + def shutdown(self) -> None: + self.tracer_provider.shutdown() + self.logger_provider.shutdown() + + +def install( + *, + tracer_provider: Optional[TracerProvider] = None, + logger_provider: Optional[LoggerProvider] = None, + instrument_openai: bool = True, + capture_content: bool = True, + set_global: bool = True, + **span_processor_kwargs, +) -> InstallHandle: + """Wire up Last9 GenAI observability in one call. + + Args: + tracer_provider: Existing provider to enrich. A new one is created if + omitted and, when ``set_global`` is true, registered as the global + tracer provider. + logger_provider: Existing logger provider to attach the log-to-span + bridge to. A new one is created if omitted and, when ``set_global`` + is true, registered as the global logger provider. + instrument_openai: If true and ``opentelemetry-instrumentation-openai-v2`` + is importable, call ``OpenAIInstrumentor().instrument()`` with the + logger provider so message / completion / tool-call events flow + through the bridge. + capture_content: If true, sets + ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`` so the + OTel GenAI instrumentations emit message bodies (required for the + bridge to have something to promote). + set_global: Register the freshly created provider(s) as OTel globals. + Pass ``False`` when you manage providers yourself or when running + tests in parallel. + **span_processor_kwargs: Extra kwargs forwarded to + :class:`Last9SpanProcessor` (``custom_pricing``, + ``enable_cost_tracking``, ``workflow_tracker``). + + Returns: + An :class:`InstallHandle` exposing both providers and both Last9 + processors so callers can add exporters or call ``shutdown()``. + + Example: + ```python + from last9_genai import install + + handle = install() + # add OTLP exporter + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, + ) + handle.tracer_provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) + ) + ``` + """ + if capture_content: + os.environ.setdefault("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "true") + + tp_created = False + if tracer_provider is None: + tracer_provider = TracerProvider() + tp_created = True + + lp_created = False + if logger_provider is None: + logger_provider = LoggerProvider() + lp_created = True + + log_bridge = Last9LogToSpanProcessor() + logger_provider.add_log_record_processor(log_bridge) + + span_processor = Last9SpanProcessor(log_processor=log_bridge, **span_processor_kwargs) + tracer_provider.add_span_processor(span_processor) + + if set_global: + if tp_created: + trace.set_tracer_provider(tracer_provider) + if lp_created: + _logs.set_logger_provider(logger_provider) + + if instrument_openai: + _maybe_instrument_openai(logger_provider) + + return InstallHandle( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + span_processor=span_processor, + log_processor=log_bridge, + ) + + +def _maybe_instrument_openai(logger_provider: LoggerProvider) -> None: + """Call OpenAIInstrumentor().instrument() if the package is installed.""" + try: + from opentelemetry.instrumentation.openai_v2 import ( # type: ignore + OpenAIInstrumentor, + ) + except ImportError: + return + + instrumentor = OpenAIInstrumentor() + if getattr(instrumentor, "_is_instrumented_by_opentelemetry", False): + # Already instrumented — don't double-wrap. + return + instrumentor.instrument(logger_provider=logger_provider) diff --git a/pyproject.toml b/pyproject.toml index 6ad2a82..2f30439 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "last9-genai" -version = "1.2.0" +version = "1.3.0" description = "Last9 observability attributes for OpenTelemetry GenAI spans - track costs, workflows, and conversations in LLM applications" readme = "README.md" license = "MIT" diff --git a/setup.py b/setup.py index d43e2cf..86d36b0 100644 --- a/setup.py +++ b/setup.py @@ -13,7 +13,7 @@ setup( name="last9-genai", - version="1.2.0", + version="1.3.0", author="Last9 Inc.", author_email="hello@last9.io", description="Last9 observability attributes for OpenTelemetry GenAI spans", diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..3d8efee --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,91 @@ +"""Unit tests for last9_genai.install.""" + +import os + +import pytest +from opentelemetry import _logs, trace +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.trace import TracerProvider + +from last9_genai import ( + InstallHandle, + Last9LogToSpanProcessor, + Last9SpanProcessor, + install, +) + + +def test_install_creates_providers_and_processors(): + handle = install(instrument_openai=False, set_global=False) + + assert isinstance(handle, InstallHandle) + assert isinstance(handle.tracer_provider, TracerProvider) + assert isinstance(handle.logger_provider, LoggerProvider) + assert isinstance(handle.span_processor, Last9SpanProcessor) + assert isinstance(handle.log_processor, Last9LogToSpanProcessor) + assert handle.span_processor.log_processor is handle.log_processor + + +def test_install_uses_provided_tracer_provider(): + tp = TracerProvider() + handle = install(tracer_provider=tp, instrument_openai=False, set_global=False) + assert handle.tracer_provider is tp + + +def test_install_uses_provided_logger_provider(): + lp = LoggerProvider() + handle = install(logger_provider=lp, instrument_openai=False, set_global=False) + assert handle.logger_provider is lp + + +def test_install_sets_capture_content_env_var(monkeypatch): + monkeypatch.delenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", raising=False) + install(instrument_openai=False, set_global=False) + assert os.environ.get("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") == "true" + + +def test_install_does_not_override_capture_content_env_var(monkeypatch): + monkeypatch.setenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "false") + install(instrument_openai=False, set_global=False) + assert os.environ.get("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") == "false" + + +def test_install_respects_capture_content_false(monkeypatch): + monkeypatch.delenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", raising=False) + install(instrument_openai=False, set_global=False, capture_content=False) + assert os.environ.get("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT") is None + + +def test_install_forwards_span_processor_kwargs(): + from last9_genai import ModelPricing + + handle = install( + instrument_openai=False, + set_global=False, + custom_pricing={"gpt-4o": ModelPricing(input=2.5, output=10.0)}, + enable_cost_tracking=False, + ) + assert handle.span_processor.enable_cost_tracking is False + assert handle.span_processor.custom_pricing is not None + assert "gpt-4o" in handle.span_processor.custom_pricing + + +def test_install_shutdown_does_not_raise(): + handle = install(instrument_openai=False, set_global=False) + handle.shutdown() + + +def test_instrument_openai_skipped_when_package_missing(monkeypatch): + import builtins + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "opentelemetry.instrumentation.openai_v2": + raise ImportError("simulated") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + handle = install(instrument_openai=True, set_global=False) + assert isinstance(handle, InstallHandle) diff --git a/uv.lock b/uv.lock index f4343ad..b14c1b9 100644 --- a/uv.lock +++ b/uv.lock @@ -794,7 +794,7 @@ wheels = [ [[package]] name = "last9-genai" -version = "1.2.0" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "opentelemetry-api" }, From 2a39252beee750f9d0158754ef0998e652c4c72e Mon Sep 17 00:00:00 2001 From: Prathamesh Sonpatki Date: Sun, 26 Apr 2026 11:46:58 +0530 Subject: [PATCH 2/3] chore: remove unused imports in test_install.py Co-Authored-By: Claude Sonnet 4.6 --- tests/test_install.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_install.py b/tests/test_install.py index 3d8efee..6ab29d6 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2,8 +2,7 @@ import os -import pytest -from opentelemetry import _logs, trace +from opentelemetry import _logs from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk.trace import TracerProvider From fc6fb7f1ba892115013dda8503b593b395c90358 Mon Sep 17 00:00:00 2001 From: Prathamesh Sonpatki Date: Sun, 26 Apr 2026 11:49:41 +0530 Subject: [PATCH 3/3] chore: remove unused _logs import in test_install.py Co-Authored-By: Claude Sonnet 4.6 --- tests/test_install.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_install.py b/tests/test_install.py index 6ab29d6..ce00bef 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2,7 +2,6 @@ import os -from opentelemetry import _logs from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk.trace import TracerProvider