diff --git a/src/glassflow/client.py b/src/glassflow/client.py index fbeae34..5334e72 100644 --- a/src/glassflow/client.py +++ b/src/glassflow/client.py @@ -27,7 +27,15 @@ 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, @@ -35,7 +43,12 @@ def build_span_exporter(config: GlassflowConfig) -> SpanExporter: 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 @@ -43,6 +56,11 @@ def __init__(self, provider: TracerProvider, config: GlassflowConfig) -> None: 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: diff --git a/src/glassflow/config.py b/src/glassflow/config.py index cc1e99a..b5d5a23 100644 --- a/src/glassflow/config.py +++ b/src/glassflow/config.py @@ -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 @@ -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 diff --git a/src/glassflow/generation.py b/src/glassflow/generation.py index 8cdfe02..e5843b6 100644 --- a/src/glassflow/generation.py +++ b/src/glassflow/generation.py @@ -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() @@ -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.``. + 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) @@ -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) diff --git a/src/glassflow/observe.py b/src/glassflow/observe.py index 2cb58e8..7b8ef4f 100644 --- a/src/glassflow/observe.py +++ b/src/glassflow/observe.py @@ -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]: diff --git a/src/glassflow/semconv.py b/src/glassflow/semconv.py index 72eac4f..e8e8b13 100644 --- a/src/glassflow/semconv.py +++ b/src/glassflow/semconv.py @@ -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. diff --git a/src/glassflow/spans.py b/src/glassflow/spans.py index 0d299e8..e04a182 100644 --- a/src/glassflow/spans.py +++ b/src/glassflow/spans.py @@ -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()