Observability Hooks

Centralized observability init and finalize (AgentOps and future providers) for PraisonAI and custom adapters

python
from praisonaiagents import Agent

agent = Agent(
    name="observable-agent",
    instructions="Run with observability hooks for tracing.",
)
agent.start("Process this request and emit trace events.")

Observability Hooks provide a centralized entry and exit point for observability providers (AgentOps, future Langfuse, W&B) in PraisonAI and custom framework adapters.

As of PR #3152, AgentsGenerator owns the observability lifecycle. Both the sync (generate_crew_and_kickoff) and async (agenerate_crew_and_kickoff) run paths bracket adapter.setup() and adapter.run()/adapter.arun() in a single observability_session. Adapters no longer initialise or finalize observability themselves — this closes the AutoGen leak (the v0.4 adapter never called finalize, so every run leaked a session) and makes finalize impossible to forget for any future adapter.

Diagram

success

exception

🚀 AgentsGenerator

🪝 observability_session

🛠️ adapter.setup

🤖 adapter.run / arun

finalize: Success

finalize: Failure

✅ AgentOps session end

praisonai.observability.hooks.init_observability(framework_tag, , tags=None) and finalize_observability(_framework_tag, , status=...) are public hooks. The generator drives both automatically through observability_session(); custom adapters called directly (not via AgentsGenerator) should use observability_session() themselves.


Quick Start

  1. Simple Usage
    bash
    # From https://app.agentops.ai
    export AGENTOPS_API_KEY=...
    python
    from praisonai.agents_generator import AgentsGenerator
    
    # init_observability and finalize_observability are called for you
    gen = AgentsGenerator("agents.yaml", "crewai", config_list=[...])
    gen.generate_crew_and_kickoff()
  2. With Configuration
    python
    from praisonai.framework_adapters.base import BaseFrameworkAdapter
    from praisonai.observability.hooks import init_observability
    
    class MyAdapter(BaseFrameworkAdapter):
        name = "myframework"
    
        def setup(self, *, framework_tag: str) -> None:
            # Add extra tags for this run
            init_observability(framework_tag, tags=["tenant=acme", "experiment=foo"])
  3. Pair init with finalize in custom adapters
    python
    from praisonai.framework_adapters.base import BaseFrameworkAdapter
    from praisonai.observability.hooks import observability_session
    
    class MyAdapter(BaseFrameworkAdapter):
        name = "myframework"
    
        def run(self, *args, **kwargs):
            with observability_session(self.name, tags=["tenant=acme"]):
                return my_framework.execute(...)

    observability_session calls init_observability on entry and finalize_observability on exit. Status is derived from sys.exc_info() automatically, so success/failure is tagged correctly with no boilerplate.

    Standalone: manual finalize for callers not going through the generator

    This pattern is valid only for standalone callers that invoke an adapter directly, outside AgentsGenerator. Do not copy it into a custom adapter that will be called by AgentsGenerator — the generator already owns init+finalize via observability_session, and a self-finalizing adapter would double-finalize.

    python
    from praisonai.framework_adapters.base import BaseFrameworkAdapter
    from praisonai.observability.hooks import init_observability, finalize_observability
    
    class MyStandaloneRunner:
        name = "myframework"
    
        def run(self, *args, **kwargs):
            init_observability(self.name, tags=["tenant=acme"])
            try:
                result = my_framework.execute(...)
                return result
            except Exception:
                raise
            finally:
                import sys
                status = "Failure" if sys.exc_info()[0] is not None else "Success"
                finalize_observability(self.name, status=status)
  4. Branch on availability
    python
    from praisonai.observability.hooks import is_agentops_available
    
    if is_agentops_available():
        ...  # do extra setup

How It Works

init_observability(framework_tag, *, tags=None) centralizes observability initialization:

  • Auto-call site: the generator opens observability_session(adapter.name) and runs _run_adapter_setup(adapter) inside the session, so setup events and any setup/import failure are recorded and finalized instead of slipping outside observability. The call sequence is:
  • _prepare_for_run(config) → validates, resolves adapter; does not run setup, does not init observability
  • with observability_session(adapter.name):
  • _run_adapter_setup(adapter) (calls adapter.setup(framework_tag=adapter.name))
  • adapter.run(...) / await adapter.arun(...)
  • AgentOps init guard: agentops.init(...) only fires if both (a) is_agentops_available() returns true, and (b) AGENTOPS_API_KEY is set in the env. Init is centralised here — agents_generator no longer double-inits AgentOps (PR #2062).
  • Failure mode: ImportError (no agentops) is logged at DEBUG; any other exception is logged at WARNING and never propagated
  • is_agentops_available() lazy function — prefer over the removed eager AGENTOPS_AVAILABLE constant in this module

finalize_observability(_framework_tag, *, status=...) closes observability sessions symmetrically:

  • Auto-call site: AgentsGenerator.generate_crew_and_kickoff (sync) and agenerate_crew_and_kickoff (async) bracket the entire adapter lifecycle — setup + run/arun — in observability_session(adapter.name). On context exit the session finalizes with status="Failure" when an exception is propagating and status="Success" otherwise. Adapters must not finalize themselves. This closes the AutoGen leak: the AutoGen adapter previously never called finalize_observability, so every AutoGen run left an ObservabilityRun, a _swapped_runs entry, and sink handles alive. For workflow YAMLs, the session is tagged with the config-resolved framework (registry default when omitted) rather than a hardcoded "praisonai" label — a fix landed in PraisonAI PR #5039.
  • AgentOps end guard: agentops.end_session(...) only fires if agentops is importable
  • Failure mode: ImportError returns silently; any other exception is logged at WARNING and never propagated
  • Why symmetric calls matter: without finalize_observability, AgentOps dashboard sessions stay stuck “in progress”

The hook also leaves room for future providers (the source already has placeholder comments for _init_langfuse and _init_wandb), so users may want to know the surface area.

Diagram
AgentOpsFramework Adapterobservability.hooksAgentsGeneratorAgentOpsFramework Adapterobservability.hooksAgentsGeneratorenter observability_session(adapter.name)init_observability → agentops.init(…)_run_adapter_setup(adapter) (adapter.setup)adapter.run(…) / await adapter.arun(…)exit observability_session (auto-finalize)finalize_observability(status derived from sys.exc_info())

Configuration

init_observability

ParameterTypeDefaultDescription
framework_tagstrrequiredPrimary tag (e.g. "crewai", "autogen_v4"). Becomes the first entry in default_tags passed to agentops.init.
tags`list[str] \None`NoneExtra tags appended after framework_tag.

finalize_observability

ParameterTypeDefaultDescription
_framework_tagstrrequiredFramework name for context (reserved for future observability providers — currently unused, but pass framework_tag for forward-compat).
statusstr (keyword-only)"Success"Session status passed to agentops.end_session(...). Conventional values: "Success", "Failure".

observability_session

ParameterTypeDefaultDescription
framework_tagstrrequiredFramework tag passed to init_observability and finalize_observability.
tags`list[str] \None`NoneExtra tags forwarded to init_observability.

Returns a context manager (ContextManager[None]). Status is auto-derived from sys.exc_info() — no status kwarg needed.

Concurrent runs

Two overlapping observability_session(...) blocks (parallel agents, nested crews) get their own AgentOps session each — tags don’t leak, and finalizing one no longer ends the other. finalize_observability resolves the owning ObservabilityRun before it calls agentops.end_session(...), so a run whose start_session returned no handle ends nothing instead of tearing down the package-global session a concurrent run still depends on. (PraisonAI #3492)

Diagram
AgentOpsobservability_session Bobservability_session AAgentOpsobservability_session Bobservability_session ASeparate handles — no cross-finalize, no tag leakstart_session → handle Astart_session → handle Bend_session(handle A)end_session(handle B)

Per-task isolation under asyncio

Each asyncio task gets its own run stack, so the void-signature finalize_observability(tag) is safe even when N concurrent arun() tasks share one worker thread.

The run stack lives in a contextvars.ContextVar, not threading.local(). asyncio copies the context per task (asyncio.create_task, asyncio.to_thread, copy_context), so _run_stack.get() in each task returns that task’s own stack — task A’s finalize_observability("crewai") pops task A’s run, never task B’s. A thread-local slot would be shared by every task on the thread, letting task A close task B’s sinks mid-run and restore another tenant’s emitter.

The value is treated as immutable (copy-on-write): _push_run, _pop_run, and _remove_run always set a brand-new list, and _get_run_stack() returns a copy. A ContextVar copied into a child context shares the same underlying list object, so mutating it in place would let a spawned task pop from its parent’s stack.

Diagram
_run_stack ContextVarTask 2Task 1_run_stack ContextVarTask 2Task 1Shared _run_stack ContextVar — each task sees a per-task copy_push_run(run1) → set [run1]_push_run(run2) → set [run2]_get_run_stack() → [run1]_get_run_stack() → [run2]finalize_observability(tag) → pop run1finalize_observability(tag) → pop run2

Prefer the explicit-handle form finalize_observability(tag, run=my_run) wherever you hold the handle; the void form is the compatibility shim for legacy call sites. Both are now concurrency-safe — the void form’s _pop_run() reaches only this context’s stack.

The regression suite test_observability_run_stack_isolation.py is the canonical guarantee: _push does not mutate a copied context, _pop in a copied context leaves the parent intact, _get_run_stack returns a copy, two concurrent asyncio tasks get isolated stacks, and _remove_run is copy-on-write. Fixed in PR #4948 (fixes #4945).

discover_observability_sinks

SignatureReturnsDescription
() -> list[Callable]List of factory callablesReturns entry-point-registered sink factories. Lazy + best-effort: broken plugins are logged at DEBUG and never break a run.
Cached after first callSame list on subsequent callsThe entry-point walk is process-static, so it is memoized after the first call. Per-run isolation is preserved because sink instances still come from calling the cached factories. Call reset_observability_sink_discovery() to invalidate.

Third-party sink plugins

Third-party packages can register an observability sink factory under the praisonai.observability_sinks entry-point group. PraisonAI discovers them lazily via discover_observability_sinks() — broken plugins are logged at DEBUG and never break a run.

Diagram

registers

discovered by

returns

called

🔌 Plugin Package

praisonai.observability_sinks entry point

discover_observability_sinks

sink factory callables

✅ Active Sinks

Register a sink (plugin authors)

toml
# pyproject.toml in your plugin package
[project.entry-points."praisonai.observability_sinks"]
my_sink = "my_package.sinks:my_sink_factory"

Your factory is a zero-arg (or framework-tag-aware) callable that returns a sink implementing the core SDK’s TraceSinkProtocol.

Discover registered sinks

python
from praisonai.observability.hooks import discover_observability_sinks

for factory in discover_observability_sinks():
    sink = factory()
    ...

Invalidating the cache

The factory list is memoized after the first discovery, so a dynamic plugin install or a test needs to invalidate it.

python
from praisonai.observability.hooks import reset_observability_sink_discovery

# After a dynamic plugin install, or between tests:
reset_observability_sink_discovery()

Cache invalidation only affects the factory list — already-running observability sessions keep their existing sinks.


Best Practices

Use observability_session for custom adapters called directly

For custom adapters that are called directly (not via AgentsGenerator), observability_session is required. It guarantees finalize_observability always runs — on success and on any failure — with the correct status derived from sys.exc_info(). This prevents AgentOps/other sessions from being orphaned in an “in progress” state on error, KeyboardInterrupt, or rate-limit paths.

When invoked via AgentsGenerator, the generator’s own session already covers the run — an adapter that opens its own inner observability_session will nest / double-init and should not.

python
from praisonai.observability.hooks import observability_session

class MyStandaloneRunner:
    name = "myframework"

    def run(self, *args, **kwargs):
        with observability_session(self.name, tags=["tenant=acme"]):
            return my_framework.execute(...)
Status convention

Use status="Success" for the happy path and status="Failure" in exception cases. The string is passed verbatim to agentops.end_session(...); future providers may map other values. When using observability_session, status is derived automatically.

Use for run-scoped tags only

The generator opens observability_session(adapter.name) once per run, which calls init_observability(adapter.name). Because setup() runs inside that session, calling init_observability again from setup() re-inits with your tags (last call wins for AgentOps). Use this for run-scoped tags only:

python
def setup(self, *, framework_tag: str) -> None:
    # Good - adds run-specific context
    init_observability(framework_tag, tags=[
        f"tenant={self.tenant_id}",
        f"experiment={self.experiment_name}"
    ])
Don't import agentops directly

Don’t import agentops at the top of your adapter — gate it behind is_agentops_available() or rely on the hook to no-op silently:

python
# ✅ Good - use the hook or check availability
from praisonai.observability.hooks import is_agentops_available, init_observability

if is_agentops_available():
    # Safe to do AgentOps-specific setup
    pass

# ❌ Bad - direct import can fail
import agentops  # May fail if not installed
AgentOps sessions are per-run

You no longer need to serialize concurrent runs to keep tags clean. Each observability_session(...) starts its own AgentOps session via start_session and ends only that run’s handle. If you were adding a wait/lock around parallel AgentsGenerator.generate_crew_and_kickoff() calls specifically to avoid AgentOps cross-contamination, you can remove it.

Future-proof for new providers

New providers (Langfuse, W&B, etc.) will be added inside _init_<provider> helpers in praisonai/observability/hooks.py — calling init_observability(...) will automatically pick them up; you don’t need to update adapter code:

python
# Future providers will be added automatically
def init_observability(framework_tag, *, tags=None):
    _init_agentops(framework_tag, tags or [])
    # _init_langfuse(framework_tag, tags)    # Future
    # _init_wandb(framework_tag, tags)       # Future

Related