Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion last9_genai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -104,6 +107,9 @@
"Last9SpanProcessor",
# Log-to-span bridge
"Last9LogToSpanProcessor",
# One-call setup helper
"install",
"InstallHandle",
# Decorators (NEW)
"observe",
]
138 changes: 138 additions & 0 deletions last9_genai/install.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions tests/test_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Unit tests for last9_genai.install."""

import os

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)
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading