Skip to content
Open
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
4 changes: 2 additions & 2 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
".": "0.23.0",
"adk": "0.23.0"
".": "0.24.0",
"adk": "0.24.0"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 75
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-330ce4f0d8feed6caeb73d6b12277cfd89f6ad85535b8c8a6f509743b0b6f8cb.yml
openapi_spec_hash: ed6b33682c511df6de538714c0864aa3
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/sgp/agentex-sdk-f639b00012e6e4329ead09cceb032a38f83db74efb88a0890505b01011942606.yml
openapi_spec_hash: 1fef57cdbefab1119cdb5b81a261f06d
config_hash: 593e89b291976a5e84e4c3c3f8324354
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@

* **tracing:** emit OTel metrics for async span queue depth, batch drain, and SGP export success/failure (HTTP status labels). Disable SDK-side recording with ``AGENTEX_TRACING_METRICS=0``.

## 0.24.0 (2026-08-11)

Full Changelog: [agentex-client-v0.23.0...agentex-client-v0.24.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.23.0...agentex-client-v0.24.0)

### Features

* **tracing:** per-step obs wrappers inside business Temporal activities ([#491](https://github.com/scaleapi/scale-agentex-python/issues/491)) ([5c7ee10](https://github.com/scaleapi/scale-agentex-python/commit/5c7ee100cbfc87b4a253b577faca1dbd7af8e686))

## 0.23.0 (2026-08-07)

Full Changelog: [agentex-client-v0.22.2...agentex-client-v0.23.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-client-v0.22.2...agentex-client-v0.23.0)
Expand Down
8 changes: 8 additions & 0 deletions adk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.24.0 (2026-08-11)

Full Changelog: [agentex-sdk-v0.23.0...agentex-sdk-v0.24.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.23.0...agentex-sdk-v0.24.0)

### Chores

* **agentex-sdk:** Synchronize agentex versions

## 0.23.0 (2026-08-07)

Full Changelog: [agentex-sdk-v0.22.2...agentex-sdk-v0.23.0](https://github.com/scaleapi/scale-agentex-python/compare/agentex-sdk-v0.22.2...agentex-sdk-v0.23.0)
Expand Down
2 changes: 1 addition & 1 deletion adk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# (agentex/{__init__.py, _*.py, types/, resources/}) ships from the slim
# sibling package `agentex-client` which is pinned as a runtime dep.
name = "agentex-sdk"
version = "0.23.0"
version = "0.24.0"
description = "Agent Development Kit (ADK) overlay for the Agentex API — FastACP server, Temporal workflows, LLM provider integrations, observability"
license = "Apache-2.0"
authors = [
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# overlay (formerly `src/agentex/lib/*`) now lives in `adk/` and ships
# as the sibling `agentex-sdk` package — see `adk/pyproject.toml`.
name = "agentex-client"
version = "0.23.0"
version = "0.24.0"
description = "The official Python REST client for the Agentex API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion src/agentex/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "agentex"
__version__ = "0.23.0" # x-release-please-version
__version__ = "0.24.0" # x-release-please-version
56 changes: 52 additions & 4 deletions src/agentex/lib/core/tracing/obs_ids.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,22 @@
from __future__ import annotations

import os
import logging
from typing import Dict, Tuple, Optional

__all__ = ("get_obs_mode", "obs_correlation")
__all__ = ("get_obs_mode", "obs_correlation", "warn_on_backend_drift")

DD_ONLY = "dd_only"
LGTM = "lgtm"
_DEFAULT_MODE = DD_ONLY
_VALID_MODES = (DD_ONLY, LGTM)

_log = logging.getLogger(__name__)
# Deduped (expected, actual) drift directions already warned about, so a genuine
# mismatch logs once instead of once per span. Bounded by construction: at most
# the 2 direction pairs ("otel"/"ddtrace" either way).
_WARNED_DRIFT: set[Tuple[str, str]] = set()


def get_obs_mode() -> str:
"""Unset/empty/unrecognized -> ``dd_only`` (current behavior)."""
Expand Down Expand Up @@ -69,7 +76,7 @@ def _ddtrace_ids() -> Optional[Tuple[str, str]]:
return None


def obs_correlation(prefer_otel: bool = False) -> Dict[str, str]:
def obs_correlation(expect_otel: bool = False) -> Dict[str, str]:
"""Return ``{"obs_trace_id": ..., "obs_span_id": ...}`` for the active
observability context, or ``{}`` if none is active.

Expand All @@ -79,15 +86,15 @@ def obs_correlation(prefer_otel: bool = False) -> Dict[str, str]:
dotted) keep them addressable via Postgres JSON paths
(``operation_metadata->>'obs_trace_id'``).

``prefer_otel``: on the Temporal path the active span is the temporalio OTel
``expect_otel``: on the Temporal path the active span is the temporalio OTel
``TracingInterceptor`` span regardless of ``SGP_OBS_MODE``, so callers there
read OTel first (falling back to ddtrace) -- otherwise the default ``dd_only``
mode would read ids for an unrelated ddtrace trace, not the activity span.

Never fabricates ids -- this is a correlation tag, not the span's id.
"""
try:
if prefer_otel:
if expect_otel:
ids = _lgtm_ids() or _ddtrace_ids()
else:
ids = _lgtm_ids() if get_obs_mode() == LGTM else _ddtrace_ids()
Expand All @@ -97,3 +104,44 @@ def obs_correlation(prefer_otel: bool = False) -> Dict[str, str]:
if not ids:
return {}
return {"obs_trace_id": ids[0], "obs_span_id": ids[1]}


def warn_on_backend_drift(expect_otel: bool = False) -> None:
"""Log once when the EXPECTED obs backend has no active span but the OTHER one
does.

Expected backend = OTel when ``expect_otel`` (the Temporal path, where the
interceptor span is OTel regardless of ``SGP_OBS_MODE``), otherwise the backend
the mode implies. A mismatch means the mode does not match the tracer actually
running at this call site -- e.g. ``dd_only`` configured but the live span is
OTel -- which is a real config/instrumentation drift worth surfacing rather
than silently correlating against whatever happens to be live.

Not a hard failure: obs stays fail-open (the caller still reads and falls back,
so no correlation is lost). The warning is deduped per direction, so a standing
mismatch logs once, not once per span. Probes the expected backend first and
returns early when it is live, so the healthy common path never touches the
other backend. Never raises."""
try:
if expect_otel or get_obs_mode() == LGTM:
expected, expected_probe, other_probe, actual = "otel", _lgtm_ids, _ddtrace_ids, "ddtrace"
else:
expected, expected_probe, other_probe, actual = "ddtrace", _ddtrace_ids, _lgtm_ids, "otel"
if expected_probe() is not None:
return # expected backend is live -> healthy; skip the other probe
if other_probe() is None:
return # nothing live at all -> uninstrumented path, not drift
if (expected, actual) not in _WARNED_DRIFT:
_WARNED_DRIFT.add((expected, actual))
_log.warning(
"obs backend drift: expected %s here (SGP_OBS_MODE=%s%s) but the "
"active span is %s; correlating against %s. Check SGP_OBS_MODE and "
"the running instrumentation.",
expected,
get_obs_mode(),
", temporal path" if expect_otel else "",
actual,
actual,
)
except Exception: # obs must never fail an app call
pass
38 changes: 27 additions & 11 deletions src/agentex/lib/core/tracing/obs_span.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ def open_obs_span(
name: str,
business_span_id: Optional[str] = None,
business_trace_id: Optional[str] = None,
expect_otel: bool = False,
) -> Optional[ObsSpanHandle]:
"""Open an obs span named ``name`` in the active backend, make it the active
span, and return a handle carrying its ``{"obs_trace_id","obs_span_id"}``.
Expand All @@ -183,6 +184,14 @@ def open_obs_span(
the reverse tag (``agentex.business_span_id`` / ``agentex.business_trace_id``)
so you can pivot obs -> business by searching them in Tempo/DD.

``expect_otel``: open an OTel wrapper first, regardless of ``SGP_OBS_MODE``.
Set on the Temporal path, where the ambient span is the temporalio OTel
``TracingInterceptor`` span regardless of mode -- an OTel wrapper nests under
it and yields valid ids, whereas the default ``dd_only`` path would open a
ddtrace wrapper, which finds no request context in a worker and returns None
(dropping the per-step span and its ids). Falls back to ddtrace if no OTel
span materializes.

Returns ``None`` (so the caller falls back to ambient behavior) when the
backend tracer isn't available or, in ``dd_only``, no request trace is
active.
Expand All @@ -192,8 +201,14 @@ def open_obs_span(
never fail an app call.
"""
try:
if get_obs_mode() == LGTM:
return _open_otel_span(name, business_span_id, business_trace_id)
if expect_otel or get_obs_mode() == LGTM:
handle = _open_otel_span(name, business_span_id, business_trace_id)
if handle is not None or not expect_otel:
# In lgtm mode a None handle means "no OTel span -> caller uses the
# ambient fallback". Only when expect_otel is set (Temporal path)
# do we try ddtrace as a second choice.
return handle
return _open_ddtrace_span(name, business_span_id, business_trace_id)
return _open_ddtrace_span(name, business_span_id, business_trace_id)
except Exception: # pragma: no cover - backstop; obs must never break a call
return None
Expand Down Expand Up @@ -236,26 +251,27 @@ def _tag_ddtrace_ambient(business_span_id: Optional[str], business_trace_id: Opt
def tag_ambient_obs_span(
business_span_id: Optional[str] = None,
business_trace_id: Optional[str] = None,
prefer_otel: bool = False,
expect_otel: bool = False,
) -> None:
"""Stamp the reverse tag onto the CURRENTLY ACTIVE obs span -- without opening
a new one.

Used on the Temporal path (see ``trace._in_temporal_activity``): there we must
NOT open our own wrapper span, because start_span/end_span run as separate
activities on possibly different workers and the wrapper could never be
closed. Instead we lean on the span the Temporal OTel ``TracingInterceptor``
already made active for this activity and just add
Used inside the SDK's dispatched start-span/end-span activities (see
``trace._in_tracing_dispatch_activity``): there we must NOT open our own
wrapper span, because start_span/end_span run as separate activities on
possibly different workers and the wrapper could never be closed. Instead we
lean on the span the Temporal OTel ``TracingInterceptor`` already made active
for this activity and just add
``agentex.business_span_id`` / ``agentex.business_trace_id`` so the obs -> business
pivot still works. Best-effort; never raises.

``prefer_otel``: on the Temporal path the ambient span is the temporalio OTel
``expect_otel``: on the Temporal path the ambient span is the temporalio OTel
``TracingInterceptor`` span REGARDLESS of ``SGP_OBS_MODE`` -- so callers there
pass ``prefer_otel=True`` to tag OTel first (falling back to ddtrace only if
pass ``expect_otel=True`` to tag OTel first (falling back to ddtrace only if
no valid OTel span is active). Without this, the default ``dd_only`` mode would
tag an unrelated ddtrace span (or nothing) instead of the real activity span."""
try:
if prefer_otel:
if expect_otel:
if _tag_otel_ambient(business_span_id, business_trace_id):
return
_tag_ddtrace_ambient(business_span_id, business_trace_id)
Expand Down
Loading
Loading