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
22 changes: 20 additions & 2 deletions src/glassflow/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,40 @@


def build_span_exporter(config: GlassflowConfig) -> SpanExporter:
"""Build the default OTLP/HTTP span exporter for a resolved config."""
"""Build the default OTLP/HTTP span exporter for a resolved config.

Args:
config: A resolved configuration; the exporter posts to
``config.traces_endpoint`` with ``config.headers``.

Returns:
A ready-to-use OTLP/HTTP ``SpanExporter``.
"""
return OTLPSpanExporter(
endpoint=config.traces_endpoint,
headers=config.headers or None,
)


class GlassflowClient:
"""Handle over a configured tracer provider."""
"""Handle over a configured tracer provider.

Returned by ``init``. Exposes the lifecycle operations (``flush``,
``shutdown``) and tracer access for the pipeline it owns; the resolved
configuration is available as ``client.config``.
"""

def __init__(self, provider: TracerProvider, config: GlassflowConfig) -> None:
self._provider = provider
self.config = config
self._is_shutdown = False

def get_tracer(self, name: str = TRACER_NAME) -> trace.Tracer:
"""Return a tracer bound to this client's provider.

Args:
name: Instrumentation scope name; defaults to the SDK's own.
"""
return self._provider.get_tracer(name, __version__)

def flush(self, timeout_millis: int = 30_000) -> bool:
Expand Down
32 changes: 30 additions & 2 deletions src/glassflow/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ def _env_float(name: str, *, default: float) -> float:

@dataclass(frozen=True)
class GlassflowConfig:
"""Resolved, immutable SDK configuration."""
"""Resolved, immutable SDK configuration.

Produced by ``resolve_config`` (arguments over environment over
defaults); consumed by ``init`` and ``build_span_exporter``.
"""

endpoint: str
api_key: str | None
Expand Down Expand Up @@ -79,7 +83,31 @@ def resolve_config(
sample_rate: float | None = None,
capture_content: bool | None = None,
) -> GlassflowConfig:
"""Resolve SDK configuration from arguments, environment, then defaults."""
"""Resolve SDK configuration from arguments, environment, then defaults.

Explicit arguments win over ``GLASSFLOW_*`` environment variables, which
win over built-in defaults. ``sample_rate`` is clamped to ``[0.0, 1.0]``
with a warning; boolean environment variables accept ``1``/``true``/
``yes``/``on`` (case-insensitive).

Args:
endpoint: Base OTLP endpoint (``GLASSFLOW_ENDPOINT``).
api_key: Bearer token for the managed platform (``GLASSFLOW_API_KEY``);
``None`` sends no Authorization header.
service_name: ``service.name`` resource attribute
(``GLASSFLOW_SERVICE_NAME``).
headers: Extra exporter headers; an explicit ``Authorization`` entry
wins over ``api_key``.
disabled: Kill switch (``GLASSFLOW_DISABLED``); spans are dropped
in-process.
sample_rate: Head-sampling ratio for root traces
(``GLASSFLOW_SAMPLE_RATE``).
capture_content: When ``False``, content attributes are stripped at
export (``GLASSFLOW_CAPTURE_CONTENT``).

Returns:
The resolved, immutable ``GlassflowConfig``.
"""
resolved_endpoint = endpoint or os.getenv(ENV_ENDPOINT) or DEFAULT_ENDPOINT
resolved_api_key = api_key if api_key is not None else os.getenv(ENV_API_KEY)
resolved_service_name = service_name or os.getenv(ENV_SERVICE_NAME) or DEFAULT_SERVICE_NAME
Expand Down
76 changes: 74 additions & 2 deletions src/glassflow/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,40 +108,91 @@ def _serialize_messages(messages: Messages, default_role: str) -> str:


class Generation:
"""Handle for recording gen_ai attributes on an LLM span."""
"""Handle for recording gen_ai attributes on an LLM span.

Returned by ``start_generation`` and ``start_as_current_generation``.
Messages passed to :meth:`set_input` / :meth:`set_output` are normalized
to the GenAI message shape (``{"role", "parts": [...]}``): bare strings,
OpenAI-style dicts (including ``tool_calls`` and tool responses), and
multimodal content lists are all accepted.
"""

def __init__(self, span: Span) -> None:
self._span = span

def set_input(self, messages: Messages) -> None:
"""Record the request messages (``gen_ai.input.messages``).

Args:
messages: A string or list of messages in any supported format;
bare strings default to the ``user`` role.
"""
self._span.set_attribute(GEN_AI_INPUT_MESSAGES, _serialize_messages(messages, "user"))

def set_output(self, messages: Messages) -> None:
"""Record the response messages (``gen_ai.output.messages``).

Args:
messages: A string or list of messages in any supported format;
bare strings default to the ``assistant`` role.
"""
self._span.set_attribute(GEN_AI_OUTPUT_MESSAGES, _serialize_messages(messages, "assistant"))

def set_response_model(self, response_model: str) -> None:
"""Record the model that produced the response (``gen_ai.response.model``).

Args:
response_model: Model identifier as reported by the provider,
which may differ from the requested model.
"""
self._span.set_attribute(GEN_AI_RESPONSE_MODEL, response_model)

def set_usage(
self, *, input_tokens: int | None = None, output_tokens: int | None = None
) -> None:
"""Record token usage (``gen_ai.usage.input_tokens`` / ``output_tokens``).

Send token counts, never cost: cost is computed server-side from
model pricing.

Args:
input_tokens: Prompt tokens consumed, when known.
output_tokens: Completion tokens produced, when known.
"""
if input_tokens is not None:
self._span.set_attribute(GEN_AI_USAGE_INPUT_TOKENS, input_tokens)
if output_tokens is not None:
self._span.set_attribute(GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens)

def set_finish_reasons(self, reasons: str | list[str]) -> None:
"""Record why generation stopped (``gen_ai.response.finish_reasons``).

Args:
reasons: A single reason (wrapped into a list) or a list of
reasons, e.g. ``"stop"``, ``"length"``, ``"tool_calls"``.
"""
if isinstance(reasons, str):
reasons = [reasons]
self._span.set_attribute(GEN_AI_RESPONSE_FINISH_REASONS, reasons)

def update(self, *, input: Messages | None = None, output: Messages | None = None) -> None:
"""Record input and/or output messages in one call.

Args:
input: When not ``None``, forwarded to :meth:`set_input`.
output: When not ``None``, forwarded to :meth:`set_output`.
"""
if input is not None:
self.set_input(input)
if output is not None:
self.set_output(output)

def end(self) -> None:
"""End the underlying span.

Required for generations created with ``start_generation``; spans from
``start_as_current_generation`` end automatically when the block exits.
"""
self._span.end()


Expand Down Expand Up @@ -181,6 +232,18 @@ def start_generation(

Manual counterpart to ``start_as_current_generation``: not set as current, no
auto-recording of exceptions.

Args:
name: Span name.
model: Requested model (``gen_ai.request.model``).
provider: Provider name (``gen_ai.provider.name``), e.g. ``"openai"``.
input: Request messages, recorded immediately via ``set_input``.
model_parameters: Request parameters, each recorded as
``gen_ai.request.<key>``.
operation: Operation name (``gen_ai.operation.name``); default ``"chat"``.

Returns:
A ``Generation`` handle; call ``.end()`` when the call completes.
"""
span = trace.get_tracer(TRACER_NAME, __version__).start_span(name)
generation = Generation(span)
Expand All @@ -205,7 +268,16 @@ def start_as_current_generation(
model_parameters: dict[str, Any] | None = None,
operation: str = "chat",
) -> Iterator[Generation]:
"""Open an LLM-kind span as the current span and yield a ``Generation``; auto-ends."""
"""Open an LLM-kind span as the current span and yield a ``Generation``; auto-ends.

Children created inside the block nest under this span, and exceptions
raised in the block are recorded with ERROR status, then re-raised.
Accepts the same arguments as ``start_generation``.

Yields:
A ``Generation`` handle for recording messages, usage, and response
metadata; the span ends when the block exits.
"""
tracer = trace.get_tracer(TRACER_NAME, __version__)
with tracer.start_as_current_span(name) as span:
generation = Generation(span)
Expand Down
16 changes: 15 additions & 1 deletion src/glassflow/observe.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,21 @@ def observe(
) -> Any:
"""Decorate a function so each call is traced as a span.

Usable bare (`@observe`) or parameterized (`@observe(name=..., ...)`).
Usable bare (``@observe``) or parameterized (``@observe(name=..., ...)``).
Supports sync functions, ``async def`` functions, generators, and async
generators; for generators the span covers the whole iteration and the
tracing context is attached only around each step. Exceptions are
recorded with ERROR status and always re-raised.

Args:
func: The decorated function (filled in by bare ``@observe`` usage).
name: Span name; defaults to the function's ``__qualname__``.
capture_input: Record call arguments as JSON in ``input.value``.
capture_output: Record the return value as JSON in ``output.value``.
kind: Span taxonomy (``openinference.span.kind``); default ``CHAIN``.

Returns:
The wrapped function (or a decorator, when used parameterized).
"""

def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
Expand Down
12 changes: 10 additions & 2 deletions src/glassflow/semconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,22 @@


class SpanKind(str, Enum):
"""Observation kind. Values are OpenInference `openinference.span.kind` values."""
"""Observation kind. Values are OpenInference ``openinference.span.kind`` values.

- ``AGENT``: an agent invocation or run
- ``LLM``: a model call (generations use this)
- ``TOOL``: a tool execution
- ``RETRIEVER``: a retrieval / search step
- ``EMBEDDING``: an embedding computation
- ``CHAIN``: a generic processing step (the default)
"""

AGENT = "AGENT"
LLM = "LLM"
TOOL = "TOOL"
RETRIEVER = "RETRIEVER"
EMBEDDING = "EMBEDDING"
CHAIN = "CHAIN" # generic processing step (OpenInference's general-purpose kind)
CHAIN = "CHAIN"


# SpanKind -> OTel GenAI gen_ai.operation.name, where a canonical operation exists.
Expand Down
35 changes: 34 additions & 1 deletion src/glassflow/spans.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,60 @@


class Observation:
"""Handle for annotating a span."""
"""Handle for annotating a span from ``start_span`` / ``start_as_current_span``.

Wraps an OpenTelemetry span and exposes the annotation surface for generic
(non-LLM) spans: input, output, and arbitrary attributes. Inputs and
outputs are serialized to JSON (with a ``repr`` fallback) and truncated at
8192 characters.
"""

def __init__(self, span: Span) -> None:
self._span = span

def set_input(self, value: Any) -> None:
"""Record the span input (the ``input.value`` attribute).

Args:
value: Any value; serialized to JSON with a ``repr`` fallback.
"""
self._span.set_attribute(INPUT_VALUE, serialize(value))

def set_output(self, value: Any) -> None:
"""Record the span output (the ``output.value`` attribute).

Args:
value: Any value; serialized to JSON with a ``repr`` fallback.
"""
self._span.set_attribute(OUTPUT_VALUE, serialize(value))

def set_attribute(self, key: str, value: Any) -> None:
"""Set an arbitrary attribute on the underlying span.

Args:
key: Attribute name.
value: An OpenTelemetry-compatible attribute value.
"""
self._span.set_attribute(key, value)

def update(self, *, input: Any = None, output: Any = None) -> None:
"""Record input and/or output in one call.

Args:
input: When not ``None``, forwarded to :meth:`set_input`.
output: When not ``None``, forwarded to :meth:`set_output`.
"""
if input is not None:
self.set_input(input)
if output is not None:
self.set_output(output)

def end(self) -> None:
"""End the underlying span.

Required for spans created with ``start_span``; spans from
``start_as_current_span`` end automatically when the block exits.
"""
self._span.end()


Expand Down