Skip to content

feat(api): add optional observability adapter - #434

Merged
harvhan merged 9 commits into
mainfrom
harvhan/runtime-observability-adapter
Sep 21, 2026
Merged

harvhan merged 9 commits into
mainfrom
harvhan/runtime-observability-adapter

Conversation

@harvhan

@harvhan harvhan commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request Summary

Add an optional module that can replace API logging, tracing, and metrics. A selected, installed adapter owns instrumentation. When the selector is unset or the module is absent, the API uses its built-in telemetry.

Changes

  • Name the selection predicate uses_observability_adapter() so its purpose is explicit.
  • Use two callbacks: initialize(app) after app assembly and shutdown() after application cleanup. Importing the adapter does not configure logging.
  • Skip native providers, custom metrics, StatsD, and PostgreSQL/Redis collectors in adapter mode. Keep the existing native startup path otherwise.
  • Let images require installed OpenTelemetry SDK setup before native auto-instrumentation, so bundled adapter packages alone do not activate it. Native custom metrics and injected SDK setup remain available.
  • Preserve request IDs through responses, cached HTTP clients, streams, and errors. Use AsyncExitStack for cleanup after startup or shutdown failures.
  • Load the SQLAlchemy engine factory after adapter initialization so its tracing hooks apply.
  • Reuse the native console handler on repeated make_logger() calls and module reloads, preserving other handlers and the existing log format.

Test Plan

  • Full local suite: 1,352 unit and integration tests passed with real PostgreSQL, MongoDB, and Redis. Includes the new bootstrap guard, native metric preservation, adapter/lifecycle selection, request IDs, and logging regressions.
  • Integration: two real Uvicorn workers passed HTTP, request-ID, per-process initialization, and shutdown checks.
  • Ruff, formatting, and whitespace checks passed.

Follow-up

Application log-call cleanup remains in #435, after this PR. Existing log message text is unchanged here. No logging helpers, lint baseline, or logging CI job is added. The adapter interface intentionally does not preserve native custom metrics when an adapter is selected. Review dashboard and alert consumers of auth_cache.access, auth_cache.eviction, and agent_run_schedule.temporal_op before activation. Request-ID validation does not prevent spoofing; deployments requiring service-controlled IDs must overwrite caller-supplied IDs at trusted ingress.

RetriggerConfidence Score: 5/5

The PR appears safe to merge. No new blocking issue was found.

What we checked:

  • Dependency update breaks supported setups: Python 3.12 Linux and Apple Silicon macOS have matching wheels. Intel macOS has a documented Rust or container path, and the existing Fernet calls still match the tested API.

Summary

The API can now hand logging, tracing, and metrics to an optional installed observability module. It also keeps request IDs connected through middleware, errors, streams, and outgoing HTTP calls while preserving the built-in telemetry path when no adapter is selected.

  • Adds adapter initialization and shutdown callbacks around the completed FastAPI app.
  • Disables native providers and collectors when an adapter owns observability.
  • Adds request-ID forwarding and safer cleanup during startup and shutdown.
  • Reuses native console handlers and documents the adapter contract.
Diagram
sequenceDiagram
    participant Worker as API worker
    participant Selector as observability selector
    participant Adapter as optional adapter
    participant Native as built-in telemetry
    participant Deps as dependencies
    participant Client as HTTP client

    Worker->>Selector: load configured module
    alt adapter is installed
        Worker->>Adapter: initialize(app)
        Worker->>Deps: start without native collectors
    else adapter is absent
        Worker->>Native: start auto-instrumentation and metrics
        Worker->>Deps: start with native collectors
    end
    Client->>Worker: request with optional x-request-id
    Worker->>Worker: keep or create request ID
    Worker-->>Client: response with x-request-id
    Worker->>Deps: close clients and dependencies
    alt adapter mode
        Worker->>Adapter: shutdown()
    else native mode
        Worker->>Native: flush metrics
    end
Loading

Reviews (9) · Last reviewed commit: "Merge branch 'main' into harvhan/runtime..."

Add a deployment-selected lifecycle hook for logging and tracing while preserving native telemetry. Keep request IDs through HTTP calls and streams, remove payload logging, and prevent new interpolated logging calls.
@harvhan
harvhan requested a review from a team as a code owner September 17, 2026 18:27
Initialize the native metric provider before the optional adapter. Move application log-call cleanup and payload assertions to a follow-up PR.
Comment thread agentex/src/utils/logging.py Outdated
Returns:
An OpenTelemetry Meter instance, or None if OTel is not configured
"""
if uses_observability_adapter():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This turns off every custom instrument in adapter mode, not just the ones sgp-obs replaces.

db_metrics is fine — sgp-obs emits the same db.client.* family. But cache_metrics (hit / miss_expired / miss_absent) and schedule_metrics (Temporal create/update/delete) have no sgp-obs equivalent, so those series simply stop when the adapter is selected.

The PR description calls this intentional, which I'm not arguing with — but it doesn't say what currently reads them. Worth enumerating the dashboards and alert rules that consume cache_* and schedule_* before anyone sets AGENTEX_OBSERVABILITY_MODULE on a real environment. If nothing does, say so in the adapter doc so the next person doesn't have to re-derive it; if something does, those need re-creating on sgp-obs' meter rather than dropping.

return

headers = scope.get("headers", [])
request_id = next(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This changes the trust boundary. Previously the ID was always minted here; now an inbound header wins. A caller can send the same x-request-id on every request and flatten log correlation for the whole service.

That's the right call behind the mesh — Envoy stamps the header, and sgp-obs' own request_id module documents exactly this assumption ("the shape check prevents log injection, not spoofing"). But observability_adapter.md describes the validator and not the assumption behind it, so there's no way to tell from the docs whether this is safe on a directly-exposed deployment.

One sentence saying inbound IDs are trusted because the mesh owns the header would cover it.

)

async def load(self):
from sqlalchemy.ext.asyncio import create_async_engine

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth a comment on why this import is function-local: SQLAlchemyInstrumentor patches create_async_engine, so a module-level import binds the unpatched symbol and the adapter silently gets no SQL spans.

As written it reads like an accident, and that reasoning currently lives only in the PR description — the next cleanup pass will hoist it back to the top of the file and the failure will be invisible.

@harvhan
harvhan merged commit 595f276 into main Sep 21, 2026
46 checks passed
@harvhan
harvhan deleted the harvhan/runtime-observability-adapter branch September 21, 2026 19:19
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.

2 participants