Skip to content

feat(obs): wire sgp-obs from the SDK for traces, metrics and logs - #523

Draft
stephen-wang24 wants to merge 5 commits into
nextfrom
stephen/agentex-sdk-sgp-obs-wiring
Draft

stephen-wang24 wants to merge 5 commits into
nextfrom
stephen/agentex-sdk-sgp-obs-wiring

Conversation

@stephen-wang24

@stephen-wang24 stephen-wang24 commented Sep 18, 2026

Copy link
Copy Markdown

Wires sgp-obs from the SDK so an agent adopts traces, metrics and logs by installing a
package and setting environment, instead of carrying the wiring itself across ~147 agent
repos.

This is #518 re-landed on next (it was reverted in #522 while the behaviour was being
measured on sgp-dev), plus three follow-up fixes found during that measurement. First
commit is a revert-of-the-revert so the add → revert → re-add history stays legible.

No version change here. The 0.27.0b2 bump that exists on the beta branch (#521) is
deliberately left behind — .release-please-manifest.json, both pyproject.tomls and
_version.py are untouched at 0.26.0, so release-please keeps ownership. #520 already
proposes 0.27.0, and these commits just add feat(obs)/fix(obs) entries to it.


Safety for agents that will never have sgp-obs

sgp-obs is served from Scale's curated CodeArtifact mirror, not public PyPI, so most
agents will not have it. This change is inert for them, and that is measured rather
than argued.

It is not a dependency, and not an extra

There is deliberately no obs extra — adk/pyproject.toml documents why (declaring it
makes this repo's own uv workspace unresolvable: locking must resolve every declared
optional dependency and neither --no-extra nor override-dependencies exempts one).
The dependency is the agent's to declare; the SDK wires it only if it is importable.

Comparing the published 0.26.0 and 0.27.0b2 wheels:

result
Declared dependencies (Requires-Dist) 32 entries each, zero differences
sgp-obs anywhere in package metadata absent
Fresh install closure, clean py3.12 venv 133 packages each, diff empty
pip install agentex-sdk with no CodeArtifact access resolves normally

No new transitive dependency either. (scale-gp-beta>=0.5.0 is sometimes attributed to
this work — it was raised in 0.23.0 and is already in current stable 0.26.0.)

Behaviour is unchanged

A 28-check A/B probe was run against both published wheels in clean venvs with no
sgp_obs, covering: module imports; logger handler count, formatter, propagation and
one-line-per-info(); BaseACPServer() construction, lifespan enter/exit, /healthz,
JSON-RPC dispatch and x-request-id handling; and the litellm gateway across happy path,
positional model (args[0]), exception propagation, CancelledError, streaming, and
mid-stream failure.

The two reports differ by one line: the version string.

Every import of sgp_obs sits inside a try. The logging rewrite is latched behind
route_loggers_to_root(), which only runs once the logs signal actually wires, so
make_logger is byte-for-byte equivalent otherwise.

The half-configured case is loud, not silent

SGP_OBS_ENABLED=true with the package absent still serves 200, reports
not_installed, and says so:

SGP_OBS_ENABLED is set but sgp-obs is not installed, so no telemetry will be produced.
Add sgp-obs[genai-auto,http,otlp] to this agent's dependencies (it resolves from Scale's
curated mirror, not public PyPI).

Independently confirmed by building and running a real agent image with no sgp-obs and
no obs environment (ilana_digital_twin on sgp-dev): image builds, sgp_obs absent,
init_sgp_obs() returns 'not_installed' and never raises, agent imports and serves, CI
green.

Builds are unaffected

All 38 scaffold Dockerfiles mount the CodeArtifact secret with required=false and carry
# syntax=docker/dockerfile:1.3, so a build with no secret proceeds on the plain install
path. These templates only affect newly scaffolded agents; an existing agent keeps its own
Dockerfile.


Two deltas that DO apply to everyone

Being straight about what is not a no-op. Neither is gated on sgp-obs.

Delta Who Detail
Shutdown up to +5s agents with SGP tracing configured shutdown_sync_tracing_processors() drains a queue nothing drained before
litellm per-call agents using LiteLLMGateway +0.2µs — within noise

Shutdown is a bug fix: the ACP lifespan only ever drained the async span queue, so a
sync agent dropped whatever business spans were still queued when the pod stopped —
including the ones an obs span's agentex.business_trace_id resolves to. The drain is
bounded (5s), runs the processors concurrently on daemon threads, and is fail-open.
Verified: a 30s stalled flush under the 5s budget returns at 5.00s and the process exits
at 5.53s, not 30s. asyncio.wait_for can stop awaiting a thread but cannot stop the
thread, and asyncio.run joins the default executor — hence daemon threads rather than
to_thread. Comfortably inside a default 30s terminationGracePeriodSeconds, but worth
knowing for SGP-tracing agents.

litellm: every completion now flows through a recorder. With sgp-obs absent that is
a stateless shared null object whose observe() is a pass-through and whose __aexit__
returns False, so it can never swallow a caller's exception. Measured safe under 50
concurrent calls.

The last commit is what makes this free. inference_call originally imported sgp_obs
per call, and Python does not cache a failed import, so it re-walked sys.path on
every model call — 62µs each with only five path entries, and a container has more. Now
resolved once, the same way base_acp_server already handles sgp_obs.context:

per call
0.26.0 11.6µs
0.27.0b2 as published 84.0µs
this branch 11.8µs

Timing note

uv excludes pre-releases from a bare agentex-sdk specifier, so 0.27.0b2 is invisible
to a fresh resolve today. Agents pinned loosely first pick this up when 0.27.0 ships
stable
— which is what merging this and then #520 does. That is the intended rollout, but
it is the moment the above stops being theoretical, which is why it was measured first.

Testing

  • Full suite on this branch: 1714 passed, 0 failed (1622 skipped)
  • Obs-specific: 112 passed with no sgp-obs installed
  • ruff check src tests clean
  • Import-cache fix carries 3 new tests that count import attempts under a counting
    __import__ hook: 50 calls give 50 attempts on the old body and 1 on this one

RetriggerConfidence Score: 5/5

The changes since the last review appear safe to merge. No new issue was confirmed.

Fix All in CursorFindings

  1. P2 The process-wide status returns before a later app reaches sgp obs.init app=... . If AgentexWorker initializes first in a combined process, or code creates a second BaseACPServer , that app never gets the middleware which this function says provides HTTP spans and incoming trace context. Keep process-wide provider setup idempotent, but track and instrument each app separately.
Fix with agent prompt
### Issue 1
src/agentex/lib/core/observability/sgp_obs_setup.py:103-120
The process-wide `_status` returns before a later app reaches `sgp_obs.init(app=...)`. If `AgentexWorker` initializes first in a combined process, or code creates a second `BaseACPServer`, that app never gets the middleware which this function says provides HTTP spans and incoming trace context. Keep process-wide provider setup idempotent, but track and instrument each app separately.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR wires optional sgp-obs support into the SDK so agents can enable traces, metrics, and logs through installation and environment settings. It also adds LiteLLM metrics, request ID correlation, bounded telemetry shutdown, and private-index support for generated agent images.

  • Adds fail-open observability setup for ACP servers and Temporal workers.
  • Records vendor-aware metrics for normal and streaming LiteLLM calls.
  • Drains telemetry during shutdown and updates generated agent images for private packages.
Diagram
sequenceDiagram
    participant App as Agent app
    participant SDK as AgentEx SDK
    participant Obs as sgp_obs
    participant Root as Root logger
    App->>SDK: Build ACP server or start worker
    SDK->>SDK: init_sgp_obs(app?)
    alt sgp_obs is unavailable
        SDK-->>App: not_installed
    else sgp_obs is available
        SDK->>Obs: "init(app=app, source="agentex")"
        Obs-->>SDK: wired signal handles
        opt logs are wired
            SDK->>Root: route_loggers_to_root()
        end
        SDK-->>App: wired status
    end
    App->>SDK: Run requests and model calls
    SDK->>Obs: Bind request ID and observe inference
    App->>SDK: Stop
    SDK->>SDK: Drain async span queue
    SDK->>SDK: Drain sync processors within 5s
    SDK->>Obs: Run shutdown() on daemon thread
    SDK-->>App: Continue after completion or 5s
Loading

Reviews (2) · Last reviewed commit: "fix(obs): drain the async span queue in ..."

stephen-wang24 and others added 4 commits September 18, 2026 09:21
…agentex's

The hand-over added in #518 had two halves, and only one of them covered an
agent's own modules. The latch in `make_logger` never looked at the logger's
name, so anything created after init was fine. The sweep matched the `agentex`
prefix, so a logger created BEFORE init under any other name kept its handler
and went on printing a second, ungoverned copy of every record.

That is not a corner case: agents call `make_logger(__name__)` from their own
modules, and `project.acp` -- the module that builds the ACP server, in every
scaffold -- logs at import, which is necessarily before `init_sgp_obs` runs.
Measured on dbt-assistant running 0.27.0b1: 123 of 3361 log lines were the
second copy, each 80 microseconds after its governed twin, carrying
`name`/`request_id` but no `trace_id`, `span_id`, `source` or `agent_id`.
Since it is emitted before the pipeline's filters, it also escapes the
allowlist and the truncation.

The SDK cannot know an agent's package name, so `make_logger` now marks each
handler it attaches and the sweep takes back exactly those, on a logger of any
name. Prefix matching stays for `agentex.*` itself, where every handler is ours
by definition. A handler this module did not attach is still left alone --
litellm's three loggers and anything else keep what their owner set up, which
is why sgp-obs warns about them rather than stripping them.

Renamed `route_agentex_loggers_to_root` to `route_loggers_to_root`, since
"agentex loggers" is what the bug was.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`request_id` had exactly one writer: `CustomJSONFormatter`, on the handler
`make_logger` attaches to each module's own logger. Removing that handler is
what stops the duplicate line -- and it takes the field's only writer with it,
so without this the id would not move to the governed copy, it would disappear.
Measured on dbt-assistant: `request_id` was on 5.2% of lines, which were
exactly the ungoverned copies, against `trace_id` on 90.6%.

The ACP middleware now binds its id into sgp-obs' shared correlation context,
which the logs pipeline enriches every record from. sgp-obs can also fill that
context from its own `RequestIdMiddleware`; binding the SDK's id instead keeps
ONE generator for the value, so the id in the logs is the id
`ctx_var_request_id` gives application code and the id `x-request-id` carried
in.

Deliberately not written onto the record here. The pipeline's enrich stage runs
on a copy of the record at handler time and treats a hand-set value as
authoritative, which cannot collide with a caller's own field. Setting the
attribute up front does collide: with `request_id` already on the record, the
stdlib raises `KeyError: Attempt to overwrite 'request_id' in LogRecord` from
`logger.info(..., extra={"request_id": ...})` -- measured, not theoretical, and
an unacceptable way for telemetry to reach an agent.

Fail-open throughout, and the optional import is resolved once: Python does not
cache a failed import, so attempting one per request would re-walk sys.path for
the majority of agents that never install sgp-obs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Python does not cache a FAILED import, so `from sgp_obs.metrics import genai`
inside inference_call re-walked sys.path on every single litellm call — for the
majority of agents, which are the ones that do not have sgp-obs installed.

Measured on the published 0.27.0b2 wheel in a clean venv with no sgp-obs, best
of 5x500 calls against a mocked transport:

    0.26.0                 11.6 us/call
    0.27.0b2 as published  84.0 us/call
    0.27.0b2 + this fix    11.8 us/call

The bare failed import is 62us of that, with only five sys.path entries; a
container image has more. Negligible beside a real model call, but it is pure
waste on the hot path, and the module docstring claimed the fallback "costs
nothing", which was the one part of it that was not true.

base_acp_server.py already solved exactly this for sgp_obs.context with an
_OBS_CONTEXT_UNRESOLVED sentinel, and said why in a comment. This applies the
same shape so the two files agree. The one-time debug line moves into the
resolver, whose body now runs exactly once — which retires the separate
_warned latch rather than leaving two latches for one fact.

Adds _reset_for_tests(), matching sgp_obs_setup.py and utils/logging.py. The
handle is process-wide state, so without it the first test to run with sgp-obs
absent would cache None for the rest of the session and every later test that
injects a fake sgp_obs.metrics would silently exercise the null path instead of
the one it means to.

Tests: 22 pass, up from 19. The new ones count import attempts under a counting
__import__ hook — 50 calls give 50 attempts on the old body and 1 on this one —
and pin that a present sgp-obs is still used on calls two and three, so the
cache cannot degrade a working install to the null path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stephen-wang24

Copy link
Copy Markdown
Author

@greptileai

Comment thread src/agentex/lib/core/temporal/workers/worker.py
Comment thread src/agentex/lib/core/observability/sgp_obs_setup.py Outdated
Comment on lines +89 to +92
if _status is not None:
# init() is not meant to run twice, and a Temporal worker plus an ACP
# server can both reach this in one process.
return _status

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The process-wide _status returns before a later app reaches sgp_obs.init(app=...). If AgentexWorker initializes first in a combined process, or code creates a second BaseACPServer, that app never gets the middleware which this function says provides HTTP spans and incoming trace context. Keep process-wide provider setup idempotent, but track and instrument each app separately.

Knowledge Base Used: Observability

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agentex/lib/core/observability/sgp_obs_setup.py
Line: 89-92

Comment:
The process-wide `_status` returns before a later app reaches `sgp_obs.init(app=...)`. If `AgentexWorker` initializes first in a combined process, or code creates a second `BaseACPServer`, that app never gets the middleware which this function says provides HTTP spans and incoming trace context. Keep process-wide provider setup idempotent, but track and instrument each app separately.

**Knowledge Base Used:** [Observability](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/scale-agentex-python/-/docs/observability.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

…obs flush

Three review findings from #523, all confirmed against the code before fixing.

**The worker dropped its own business spans (P1).** Its `finally` called the sync
and sgp-obs drains but not `shutdown_default_span_queue()`. Standard Temporal
activities trace through `AsyncTracer` (core/temporal/activities/__init__.py:66),
and `AsyncTrace.__init__` takes `get_default_span_queue()` when no queue is passed
(core/tracing/trace.py:406) — so a worker's spans sit in exactly the queue nothing
drained. The async drain now runs first, matching the ACP lifespan's order, and a
test asserts both paths carry all three drains so they cannot drift apart again.

**The sgp-obs flush had no deadline (P1).** `asyncio.to_thread(shutdown)` was
awaited unbounded, so a hung exporter held both callers until the pod was killed.
Worse, `asyncio.run` joins the default executor on the way out, so even adding a
`wait_for` would not have helped — the process still blocks on the export the
deadline was meant to escape. Measured, 20s stalled flush under a 0.25s budget:

    asyncio.to_thread + wait_for   process exits at 20.04s
    daemon thread + wait_for       process exits at  0.31s

Now a daemon thread under a 5s budget, the same shape and the same reasoning as
`shutdown_sync_tracing_processors`, whose docstring already spelled this out. The
overrun is warned about rather than silent. Covered by a subprocess test, because
interpreter shutdown cannot be observed from inside the test process.

**A second app was silently uninstrumented (P2).** `init()` is process-wide and
must not run twice, but the ASGI instrumentation it installs is per-app, so a
later `BaseACPServer` — or an ACP server built after `AgentexWorker.run()`, which
inits with no app — got no `http.server.*` and no ingress trace continuation, with
nothing saying so. This warns instead, naming the ordering that causes it.

That last one is deliberately a diagnostic rather than a repair: instrumenting the
second app would mean calling into sgp-obs for a per-app entry point I cannot
verify from here (it is not a dependency of this package and not on public PyPI),
and guessing at an API is worse than making the silent case audible. Flagged for
follow-up.

Tests: 63 in the two obs suites, 1714 in the repo suite, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@stephen-wang24

Copy link
Copy Markdown
Author

@greptileai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant