Skip to content

symfonic.platform.observability

observability

Observability the host owns, rather than the process.

Three open gaps say the same thing from three angles, and the middle one says it out loud.

PUB-5 names ConversationMetricsCollector: what makes a child's cost roll up into its parent's totals. Without a public name, a generated app's delegation costs vanish from the billing view.

PUB-6 names the metrics store's factory and its get/set module globals. It is triaged "internalize store internals" while the scaffold's admin dashboard and worker depend on exactly that seam, and the gap row states the contradiction: the verdict and the shipped scaffold disagree; one of them has to move.

PUB-7 names PostgresBudgetStore: without it two replicas each enforce their own half of the limit.

One resolution answers all three, and it is the one the host already established. These are process resources. The host owns them and hands them over, which honours PUB-6's verdict rather than overturning it -- the store internals stay internal, and what becomes public is a bundle a composition root holds. It also removes the module globals, which a multi-tenant host should never have had: set_metrics_store means the second host built in a process overwrites the first, silently, and the first host's dashboard then reads the second's numbers.

One collector, not one per agent. The roll-up is the sharing; there is no separate mechanism. A child reporting to its own collector would produce two correct ledgers and no total, which is why the collector belongs here and not to an agent.

ObservabilityServices dataclass

ObservabilityServices(collector: Any, metrics: Any = None, budget: Any = None, event_sinks: tuple[Any, ...] = ())

What a process needs to account for what its agents did.

Satisfies :class:~symfonic.platform.host.SharedResources structurally, so a composition root hands this to the host it already builds rather than installing it anywhere.

There is deliberately no setter. Ownership runs one way: the host holds these, and nothing installs them into a place other code discovers.

budget_is_shared property

budget_is_shared: bool

Whether two replicas would enforce one limit or two halves of it.

Exposed because in-memory is a legitimate choice and a silent one is not: a deployment may accept per-replica budgets, but it may not be unable to tell that is what it has.

aclose async

aclose() -> None

Release what these own. Idempotent, because shutdown paths repeat.

Source code in src/symfonic/platform/observability.py
async def aclose(self) -> None:
    """Release what these own. Idempotent, because shutdown paths repeat."""
    for held in (self.metrics, self.budget, *self.event_sinks):
        closer = getattr(held, "aclose", None)
        if callable(closer):
            await closer()

observability

observability(*, metrics_identifier: str | None = None, postgres_session_factory: Any = None, mongo_database_factory: Any = None, mongo_retention_days: int = 90) -> ObservabilityServices

Build one process's observability resources.

Parameters:

Name Type Description Default
metrics_identifier str | None

which durable metrics store to build, if any.

None
postgres_session_factory Any

supplied when budget consumption must be counted across replicas. Absent, the tracker is in-memory and each replica enforces its own half of the limit -- which :attr:ObservabilityServices.budget_is_shared reports rather than leaving to be discovered.

None
mongo_database_factory Any

the alternative durable metrics backend.

None
mongo_retention_days int

how long that backend keeps rows.

90
Source code in src/symfonic/platform/observability.py
def observability(
    *,
    metrics_identifier: str | None = None,
    postgres_session_factory: Any = None,
    mongo_database_factory: Any = None,
    mongo_retention_days: int = 90,
) -> ObservabilityServices:
    """Build one process's observability resources.

    Args:
        metrics_identifier: which durable metrics store to build, if any.
        postgres_session_factory: supplied when budget consumption must be
            counted across replicas. Absent, the tracker is in-memory and each
            replica enforces its own half of the limit -- which
            :attr:`ObservabilityServices.budget_is_shared` reports rather than
            leaving to be discovered.
        mongo_database_factory: the alternative durable metrics backend.
        mongo_retention_days: how long that backend keeps rows.
    """
    from symfonic.core.observability.metrics import ConversationMetricsCollector
    from symfonic.core.observability.metrics_store import (
        BufferedMetricsSink,
        make_metrics_store,
    )

    backend = make_metrics_store(
        metrics_identifier,
        postgres_session_factory=postgres_session_factory,
        mongo_database_factory=mongo_database_factory,
        mongo_retention_days=mongo_retention_days,
    )
    metrics = BufferedMetricsSink(backend) if backend is not None else None

    budget = None
    if postgres_session_factory is not None:
        from symfonic.core.observability.postgres_budget_store import (
            PostgresBudgetStore,
        )

        budget = PostgresBudgetStore(postgres_session_factory)

    return ObservabilityServices(
        collector=ConversationMetricsCollector(metrics_sink=metrics),
        metrics=metrics,
        budget=budget,
    )