agentship-observability 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ """AgentShip observability — the OpenTelemetry pipeline behind the vendor-free ``Observer`` port.
2
+
3
+ The contract (port, ``SpanKind``/``Usage``, SEMCONV, ``TraceView``) lives in ``agentship-core``;
4
+ this package supplies the concrete OTel ``OTelObserver``, the
5
+ exporter pipeline, the one-per-process LiteLLM cost/token callback, the record/replay capture hook,
6
+ and the studio launcher (design §4.6). Import ``OTelObserver`` here; build a configured one with
7
+ :func:`~agentship_observability.factory.build_otel_observer`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .otel import OTelObserver
13
+
14
+ __all__ = ["OTelObserver"]
@@ -0,0 +1,57 @@
1
+ """``ObservabilityConfig`` — the ``observability:`` config surface (C5).
2
+
3
+ One small Pydantic model parses the ``observability:`` block from an agent's YAML (secrets come from
4
+ the environment, never the file). Defaults are the safe ones: tracing on, the OTel provider, the
5
+ ``console`` exporter (which needs no running backend), content capture **off** (the PHI gate), user
6
+ ids hashed, full sampling, and SaaS exporters blocked. Phoenix is the recommended production OSS
7
+ exporter — a one-line ``exporters: [phoenix]`` swap — but is not the zero-config default because it
8
+ needs a collector to point at.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, Field, field_validator
16
+
17
+ #: The exporter names the factory knows how to build. Extend here + in ``exporters/factory.py``.
18
+ ExporterName = Literal["console", "phoenix", "langfuse", "langsmith", "opik"]
19
+
20
+
21
+ class ObservabilityConfig(BaseModel):
22
+ """Declarative tracing config for one agent; the factory turns it into an ``Observer``.
23
+
24
+ ``provider`` selects the implementation: ``otel`` (the default) builds an ``OTelObserver`` over
25
+ a process-global ``TracerProvider``; ``none`` yields the no-op observer so a run is untraced but
26
+ unchanged. ``exporters`` is a list so a span tree can fan out to several backends at once.
27
+ """
28
+
29
+ model_config = {"extra": "forbid"}
30
+
31
+ #: Master switch. When false the factory yields the no-op observer regardless of ``provider``.
32
+ enabled: bool = True
33
+ #: The observer implementation: ``otel`` (default) or ``none`` (no-op).
34
+ provider: Literal["otel", "none"] = "otel"
35
+ #: Backends to export to; every span tree is sent to each. Default console (needs no backend).
36
+ exporters: list[ExporterName] = Field(default_factory=lambda: ["console"])
37
+ #: PHI gate — when false, prompt/response content is never put on a span (§4.6). Default off.
38
+ capture_content: bool = False
39
+ #: Hash the caller's user id on spans so a raw id never lands in a trace store. Default on.
40
+ hash_user_id: bool = True
41
+ #: Parent-based sampling ratio, 0.0–1.0. 1.0 keeps every trace; lower drops a fraction.
42
+ sample_ratio: float = 1.0
43
+ #: Allow a SaaS exporter (LangSmith) to receive content-bearing spans. Off in the PHI profile.
44
+ allow_saas_exporter: bool = False
45
+
46
+ @field_validator("sample_ratio")
47
+ @classmethod
48
+ def _ratio_in_range(cls, value: float) -> float:
49
+ """Reject a sampling ratio outside 0.0–1.0 — a typo here silently loses or floods traces."""
50
+ if not 0.0 <= value <= 1.0:
51
+ raise ValueError(f"sample_ratio must be between 0.0 and 1.0, got {value}")
52
+ return value
53
+
54
+ @property
55
+ def uses_saas_exporter(self) -> bool:
56
+ """Whether any configured exporter ships spans to a SaaS backend (currently LangSmith)."""
57
+ return "langsmith" in self.exporters
@@ -0,0 +1,13 @@
1
+ """Exporter builders — each turns a configured backend name into an OTel ``SpanProcessor``.
2
+
3
+ One module per backend (console, phoenix, langfuse, langsmith, opik); :func:`build_processor` in
4
+ ``factory`` dispatches a config's ``exporters`` list to them. Every builder returns a ready
5
+ ``SpanProcessor`` the tracer provider can register, so adding a backend is a one-file change plus a
6
+ line in the dispatch table.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .factory import build_processor
12
+
13
+ __all__ = ["build_processor"]
@@ -0,0 +1,24 @@
1
+ """Console exporter — prints finished spans to stderr. The zero-backend default (§C4).
2
+
3
+ Needs nothing running, so it is the safe default: a dev can see the span tree immediately and an
4
+ offline CI run never fails on a missing collector. Uses a ``SimpleSpanProcessor`` (synchronous
5
+ export) because console output is cheap and immediate feedback beats batching here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+ from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor, SpanProcessor
13
+
14
+ from ..config import ObservabilityConfig
15
+
16
+
17
+ def build(config: ObservabilityConfig) -> SpanProcessor:
18
+ """Build a synchronous processor that prints each finished span to stderr.
19
+
20
+ Spans go to **stderr**, not stdout: the CLI reserves stdout for the agent's answer so a piped
21
+ run is never polluted by trace output. ``ConsoleSpanExporter`` defaults to stdout, so the target
22
+ stream is set explicitly here.
23
+ """
24
+ return SimpleSpanProcessor(ConsoleSpanExporter(out=sys.stderr))
@@ -0,0 +1,38 @@
1
+ """Dispatch a config's ``exporters`` list to per-backend processor builders (§C4).
2
+
3
+ One tiny lookup table maps each exporter name to its module's ``build(config)``. Adding a backend is
4
+ a one-line change here plus its module. An unknown name is a fail-fast :class:`CapabilityError` with
5
+ the known names listed, so a typo in YAML surfaces at build time, not as silent missing traces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+
12
+ from agentship.errors import CapabilityError
13
+ from opentelemetry.sdk.trace.export import SpanProcessor
14
+
15
+ from ..config import ObservabilityConfig
16
+ from . import console, langfuse, langsmith, opik, phoenix
17
+
18
+ #: Exporter name → builder. Keys must match ``config.ExporterName``.
19
+ _BUILDERS: dict[str, Callable[[ObservabilityConfig], SpanProcessor]] = {
20
+ "console": console.build,
21
+ "phoenix": phoenix.build,
22
+ "langfuse": langfuse.build,
23
+ "langsmith": langsmith.build,
24
+ "opik": opik.build,
25
+ }
26
+
27
+
28
+ def build_processor(name: str, config: ObservabilityConfig) -> SpanProcessor:
29
+ """Build the ``SpanProcessor`` for one exporter ``name``.
30
+
31
+ Raises :class:`CapabilityError` (listing the known names) when ``name`` is not a registered
32
+ exporter, so a misconfigured ``exporters:`` entry fails loudly at build time.
33
+ """
34
+ builder = _BUILDERS.get(name)
35
+ if builder is None:
36
+ known = ", ".join(sorted(_BUILDERS))
37
+ raise CapabilityError(f"unknown exporter {name!r}; known exporters: {known}")
38
+ return builder(config)
@@ -0,0 +1,41 @@
1
+ """Langfuse exporter — OTLP/HTTP to a Langfuse project (§C4).
2
+
3
+ Langfuse (self-hostable or SaaS) accepts OTLP at ``${LANGFUSE_HOST}/api/public/otel/v1/traces`` and
4
+ authenticates with HTTP Basic over the project's public/secret key pair. Keys and host come from the
5
+ environment (never the config file) so a secret never lands in checked-in YAML.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import os
12
+
13
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
14
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanProcessor
15
+
16
+ from ..config import ObservabilityConfig
17
+
18
+ #: Langfuse Cloud host used when ``LANGFUSE_HOST`` is unset; self-hosters override it.
19
+ DEFAULT_HOST = "https://cloud.langfuse.com"
20
+ #: OTLP traces path appended to the host.
21
+ OTEL_PATH = "/api/public/otel/v1/traces"
22
+
23
+
24
+ def _basic_auth_header(public_key: str, secret_key: str) -> str:
25
+ """Return the ``Basic <b64>`` value for Langfuse's public/secret key pair."""
26
+ token = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
27
+ return f"Basic {token}"
28
+
29
+
30
+ def build(config: ObservabilityConfig) -> SpanProcessor:
31
+ """Build a batched OTLP/HTTP processor pointed at Langfuse with Basic-auth headers.
32
+
33
+ Reads ``LANGFUSE_HOST`` (optional), ``LANGFUSE_PUBLIC_KEY`` and ``LANGFUSE_SECRET_KEY`` from the
34
+ environment. Missing keys still build a processor (fail-open) — the backend simply rejects the
35
+ unauthenticated export rather than breaking the agent run.
36
+ """
37
+ host = os.getenv("LANGFUSE_HOST", DEFAULT_HOST).rstrip("/")
38
+ public_key = os.getenv("LANGFUSE_PUBLIC_KEY", "")
39
+ secret_key = os.getenv("LANGFUSE_SECRET_KEY", "")
40
+ headers = {"Authorization": _basic_auth_header(public_key, secret_key)}
41
+ return BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{host}{OTEL_PATH}", headers=headers))
@@ -0,0 +1,32 @@
1
+ """LangSmith exporter — OTLP/HTTP to LangSmith. The one SaaS backend, gated (§C4, §4.6).
2
+
3
+ LangSmith is a hosted service, so spans leave the boundary. The factory only wires it in when the
4
+ config's ``allow_saas_exporter`` is set — this module just builds the processor. Endpoint and
5
+ ``x-api-key`` come from the environment; an optional ``LANGSMITH_PROJECT`` tags the traces.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
13
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanProcessor
14
+
15
+ from ..config import ObservabilityConfig
16
+
17
+ #: LangSmith's hosted OTLP traces endpoint when ``LANGSMITH_OTEL_ENDPOINT`` is unset.
18
+ DEFAULT_ENDPOINT = "https://api.smith.langchain.com/otel/v1/traces"
19
+
20
+
21
+ def build(config: ObservabilityConfig) -> SpanProcessor:
22
+ """Build a batched OTLP/HTTP processor pointed at LangSmith with its ``x-api-key`` header.
23
+
24
+ Reads ``LANGSMITH_OTEL_ENDPOINT`` (optional), ``LANGSMITH_API_KEY`` and ``LANGSMITH_PROJECT``
25
+ (optional) from the environment. The SaaS gate lives in the factory, not here.
26
+ """
27
+ endpoint = os.getenv("LANGSMITH_OTEL_ENDPOINT", DEFAULT_ENDPOINT)
28
+ headers = {"x-api-key": os.getenv("LANGSMITH_API_KEY", "")}
29
+ project = os.getenv("LANGSMITH_PROJECT")
30
+ if project:
31
+ headers["Langsmith-Project"] = project
32
+ return BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, headers=headers))
@@ -0,0 +1,43 @@
1
+ """Opik exporter — OTLP/HTTP to an Opik backend (Comet's open-source LLM tracing tool) (§C4).
2
+
3
+ Opik is open-source and self-hostable, so the default endpoint is the local Opik server; it also
4
+ reads the GenAI/OpenInference attributes the observer stamps. As with Phoenix we reach it over the
5
+ **standard OTLP/HTTP exporter** rather than Opik's SDK, keeping the pipeline vendor-neutral — Opik
6
+ is just an OTLP endpoint here (integrate, don't reinvent).
7
+
8
+ The endpoint comes from ``OPIK_OTEL_ENDPOINT`` (default the local server). Auth is optional so the
9
+ local, keyless case just works: when ``OPIK_API_KEY`` is set an ``Authorization`` header is sent,
10
+ and ``OPIK_WORKSPACE`` / ``OPIK_PROJECT_NAME`` add the workspace and project headers Opik Cloud
11
+ expects. Pointing this at Opik Cloud sends spans off-box — the same SaaS consideration the
12
+ factory's ``allow_saas_exporter`` gate covers when the endpoint is a hosted one.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
20
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanProcessor
21
+
22
+ from ..config import ObservabilityConfig
23
+
24
+ #: Where a local self-hosted Opik server accepts OTLP/HTTP traces when nothing else is configured.
25
+ DEFAULT_ENDPOINT = "http://localhost:5173/api/v1/private/otel/v1/traces"
26
+
27
+
28
+ def build(config: ObservabilityConfig) -> SpanProcessor:
29
+ """Build a batched OTLP/HTTP processor pointed at Opik, with optional auth/workspace headers.
30
+
31
+ Reads ``OPIK_OTEL_ENDPOINT`` (optional) and, for Opik Cloud, the optional ``OPIK_API_KEY``,
32
+ ``OPIK_WORKSPACE`` and ``OPIK_PROJECT_NAME``. With none of them set it targets a local Opik
33
+ server with no auth, so the offline/self-hosted path needs no configuration.
34
+ """
35
+ endpoint = os.getenv("OPIK_OTEL_ENDPOINT", DEFAULT_ENDPOINT)
36
+ headers: dict[str, str] = {}
37
+ if api_key := os.getenv("OPIK_API_KEY"):
38
+ headers["Authorization"] = api_key
39
+ if workspace := os.getenv("OPIK_WORKSPACE"):
40
+ headers["Comet-Workspace"] = workspace
41
+ if project := os.getenv("OPIK_PROJECT_NAME"):
42
+ headers["projectName"] = project
43
+ return BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, headers=headers))
@@ -0,0 +1,32 @@
1
+ """Phoenix exporter — OTLP/HTTP to an Arize Phoenix collector. The recommended OSS backend (§C4).
2
+
3
+ Phoenix is self-hosted and open-source, reads the GenAI + OpenInference attributes the observer
4
+ stamps, and needs no API key — just a running collector. The endpoint comes from
5
+ ``PHOENIX_COLLECTOR_ENDPOINT`` (default the local dev collector), so pointing at a shared Phoenix is
6
+ an env-var change, not a code change.
7
+
8
+ We deliberately reach Phoenix over the **standard OTLP/HTTP exporter**, not ``arize-phoenix-otel``'s
9
+ ``register()`` convenience. ``register()`` installs a *global* tracer provider; our observer
10
+ composes this processor into its own provider (multiple exporters, one pipeline), so that global
11
+ grab would fight our factory. Using plain OTLP keeps us vendor-neutral with zero Phoenix-SDK
12
+ lock-in — Phoenix
13
+ is just an OTLP endpoint here — which is exactly the integrate-don't-reinvent contract.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+
20
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
21
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanProcessor
22
+
23
+ from ..config import ObservabilityConfig
24
+
25
+ #: Where Phoenix listens for OTLP/HTTP traces when nothing else is configured (local dev collector).
26
+ DEFAULT_ENDPOINT = "http://localhost:6006/v1/traces"
27
+
28
+
29
+ def build(config: ObservabilityConfig) -> SpanProcessor:
30
+ """Build a batched OTLP/HTTP processor pointed at the Phoenix collector."""
31
+ endpoint = os.getenv("PHOENIX_COLLECTOR_ENDPOINT", DEFAULT_ENDPOINT)
32
+ return BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint))
@@ -0,0 +1,122 @@
1
+ """Build an :class:`Observer` from an :class:`ObservabilityConfig` (§C5).
2
+
3
+ The public seam the rest of AgentShip calls. :func:`build_observer` maps a config to a concrete
4
+ observer: ``provider: none`` (or ``enabled: false``) yields the no-op observer so a run is untraced
5
+ but unchanged; ``provider: otel`` builds an :class:`OTelObserver` over a **process-global**
6
+ ``TracerProvider``.
7
+
8
+ The provider is memoized (§DoD "one process-global"): every agent in a process shares one provider,
9
+ so exporters and their batching threads are created once, not per agent. The memo key is the config
10
+ signature that affects the pipeline (service name, exporters, sampling), so two agents with the same
11
+ observability block reuse the same provider and two with different blocks each get their own.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from agentship.errors import CapabilityError
19
+ from agentship.observability import NoOpObserver, Observer
20
+ from agentship.spec import ObservabilitySpec
21
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
22
+ from opentelemetry.sdk.trace import TracerProvider
23
+ from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
24
+
25
+ from .config import ObservabilityConfig
26
+ from .exporters import build_processor
27
+ from .otel import OTelObserver
28
+
29
+ #: The OTel ``service.name`` for every AgentShip span, overridable via env for multi-service setups.
30
+ DEFAULT_SERVICE_NAME = "agentship"
31
+
32
+ #: Process-global cache of built providers, keyed by :func:`_provider_key`.
33
+ _PROVIDERS: dict[tuple, TracerProvider] = {}
34
+
35
+
36
+ def _service_name() -> str:
37
+ """Resolve the ``service.name`` resource attribute (env override, then default)."""
38
+ return os.getenv("AGENTSHIP_SERVICE_NAME", DEFAULT_SERVICE_NAME)
39
+
40
+
41
+ def _resolved_exporters(config: ObservabilityConfig) -> list[str]:
42
+ """Return the exporter names to wire, enforcing the SaaS gate (§4.6).
43
+
44
+ A SaaS exporter (LangSmith) ships spans off-box, so it is only allowed when the config opts in
45
+ with ``allow_saas_exporter``. Otherwise it is a fail-fast :class:`CapabilityError` rather than a
46
+ silent data-egress the operator did not ask for.
47
+ """
48
+ if config.uses_saas_exporter and not config.allow_saas_exporter:
49
+ raise CapabilityError(
50
+ "exporter 'langsmith' ships spans to a SaaS backend; set allow_saas_exporter: true "
51
+ "to enable it"
52
+ )
53
+ return list(config.exporters)
54
+
55
+
56
+ def _provider_key(config: ObservabilityConfig) -> tuple:
57
+ """Build the memo key: the config fields that change the provider's pipeline."""
58
+ return (_service_name(), tuple(config.exporters), config.sample_ratio)
59
+
60
+
61
+ def build_tracer_provider(config: ObservabilityConfig) -> TracerProvider:
62
+ """Return the process-global ``TracerProvider`` for ``config``, building it once.
63
+
64
+ The provider carries the ``service.name`` resource and a ``ParentBased(TraceIdRatioBased)``
65
+ sampler (so a child inherits its root's keep/drop decision) and registers one span processor per
66
+ configured exporter.
67
+ """
68
+ key = _provider_key(config)
69
+ provider = _PROVIDERS.get(key)
70
+ if provider is not None:
71
+ return provider
72
+
73
+ resource = Resource.create({SERVICE_NAME: _service_name()})
74
+ sampler = ParentBased(TraceIdRatioBased(config.sample_ratio))
75
+ provider = TracerProvider(resource=resource, sampler=sampler)
76
+ for name in _resolved_exporters(config):
77
+ provider.add_span_processor(build_processor(name, config))
78
+
79
+ _PROVIDERS[key] = provider
80
+ return provider
81
+
82
+
83
+ def _as_config(config: ObservabilityConfig | ObservabilitySpec | None) -> ObservabilityConfig:
84
+ """Coerce the declarative :class:`ObservabilitySpec` (or ``None``) into an adapter config.
85
+
86
+ The kernel's ``observability:`` block is an :class:`~agentship.spec.ObservabilitySpec` — a
87
+ vendor-neutral authoring surface. This maps its fields onto the adapter's
88
+ :class:`ObservabilityConfig`, which is where exporter names are actually validated (against the
89
+ backends this package can build) and env-driven endpoints/keys are resolved. A pass-through
90
+ ``ObservabilityConfig`` is returned as-is, and ``None`` yields the console-only default.
91
+ """
92
+ if isinstance(config, ObservabilitySpec):
93
+ return ObservabilityConfig(**config.model_dump())
94
+ return config or ObservabilityConfig()
95
+
96
+
97
+ def build_otel_observer(
98
+ config: ObservabilityConfig | ObservabilitySpec | None = None,
99
+ ) -> OTelObserver:
100
+ """Build an :class:`OTelObserver` over the process-global provider for ``config``.
101
+
102
+ Called by the ``agentship.observers`` entry point (``otel``) with an agent's
103
+ :class:`~agentship.spec.ObservabilitySpec`, and by tests with an :class:`ObservabilityConfig`
104
+ directly. Either is coerced by :func:`_as_config`; a bare call still yields a working
105
+ console-exporting observer.
106
+ """
107
+ config = _as_config(config)
108
+ provider = build_tracer_provider(config)
109
+ return OTelObserver(provider, capture_content=config.capture_content)
110
+
111
+
112
+ def build_observer(config: ObservabilityConfig | None = None) -> Observer:
113
+ """Map a config to a concrete observer: no-op when disabled/``none``, else the OTel observer."""
114
+ config = config or ObservabilityConfig()
115
+ if not config.enabled or config.provider == "none":
116
+ return NoOpObserver()
117
+ return build_otel_observer(config)
118
+
119
+
120
+ def _reset_providers_for_tests() -> None:
121
+ """Clear the process-global provider cache. For tests that assert build-once behaviour."""
122
+ _PROVIDERS.clear()
@@ -0,0 +1,152 @@
1
+ """The one LiteLLM callback that turns a completed model call into span usage (P07 · C3).
2
+
3
+ Every model call in AgentShip goes through LiteLLM. LiteLLM fires success callbacks with the token
4
+ counts, resolved model, finish reasons, and computed cost — exactly the roll-up the ``model`` span
5
+ wants. This module registers **one** process-global :class:`CustomLogger` that, on each success,
6
+ builds a :class:`Usage` and hands it to the observer's :meth:`on_model`, which stamps it onto the
7
+ currently-active model span (§4.3).
8
+
9
+ The callback carries no per-run state: ``on_model`` targets whatever model span is current on the
10
+ OTel context, so a single registered logger serves every concurrent agent. Registration is
11
+ idempotent — calling :func:`register_litellm_logger` twice adds the callback once (§DoD "one
12
+ process-global LiteLLM callback").
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from datetime import datetime
19
+ from typing import Any
20
+
21
+ import litellm
22
+ from agentship.observability import Observer, Usage, replay_attributes
23
+ from litellm.integrations.custom_logger import CustomLogger
24
+
25
+ _log = logging.getLogger("agentship.observability")
26
+
27
+
28
+ def _latency_ms(start_time: Any, end_time: Any) -> float:
29
+ """Milliseconds between LiteLLM's start/end markers; 0.0 if they aren't usable datetimes."""
30
+ if isinstance(start_time, datetime) and isinstance(end_time, datetime):
31
+ return (end_time - start_time).total_seconds() * 1000.0
32
+ return 0.0
33
+
34
+
35
+ def _provider_of(kwargs: dict[str, Any], model: str) -> str:
36
+ """Best-effort LLM provider (``openai``, ``anthropic``, …) from kwargs, else the model prefix.
37
+
38
+ LiteLLM usually sets ``custom_llm_provider``; when it doesn't, fall back to the
39
+ ``provider/model`` prefix, and finally to ``unknown`` rather than raising inside a callback.
40
+ """
41
+ provider = kwargs.get("custom_llm_provider")
42
+ if provider:
43
+ return str(provider)
44
+ if "/" in model:
45
+ return model.split("/", 1)[0]
46
+ return "unknown"
47
+
48
+
49
+ def _finish_reasons(response_obj: Any) -> list[str]:
50
+ """Collect non-empty ``finish_reason`` values from a response's choices."""
51
+ reasons: list[str] = []
52
+ for choice in getattr(response_obj, "choices", None) or []:
53
+ reason = getattr(choice, "finish_reason", None)
54
+ if reason:
55
+ reasons.append(str(reason))
56
+ return reasons
57
+
58
+
59
+ def usage_from_litellm(
60
+ kwargs: dict[str, Any], response_obj: Any, start_time: Any, end_time: Any
61
+ ) -> Usage | None:
62
+ """Build a :class:`Usage` from a LiteLLM success callback's arguments, or ``None`` if unusable.
63
+
64
+ Reads token counts from ``response_obj.usage``, the resolved model from the response, the cost
65
+ from ``kwargs['response_cost']`` (LiteLLM computes it), and latency from the start/end markers.
66
+ Returns ``None`` when there is no usage block to report, so the caller can skip stamping.
67
+ """
68
+ usage = getattr(response_obj, "usage", None)
69
+ if usage is None:
70
+ return None
71
+ requested_model = str(kwargs.get("model") or getattr(response_obj, "model", "") or "")
72
+ response_model = getattr(response_obj, "model", None)
73
+ cost = kwargs.get("response_cost")
74
+ return Usage(
75
+ model=requested_model,
76
+ provider=_provider_of(kwargs, requested_model),
77
+ input_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
78
+ output_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
79
+ cost_usd=float(cost) if cost is not None else None,
80
+ latency_ms=_latency_ms(start_time, end_time),
81
+ finish_reasons=_finish_reasons(response_obj),
82
+ response_model=str(response_model) if response_model else None,
83
+ )
84
+
85
+
86
+ class AgentShipLiteLLMLogger(CustomLogger):
87
+ """A LiteLLM callback that stamps each completed model call onto the active model span.
88
+
89
+ Holds a reference to an :class:`Observer` only to reach :meth:`Observer.on_model`; the span it
90
+ writes to is the one current on the trace context, not anything this logger tracks. Both the
91
+ sync and async success hooks funnel through :meth:`_emit`, which is fail-open — a bad callback
92
+ payload is logged and dropped, never raised into LiteLLM's request path.
93
+ """
94
+
95
+ def __init__(self, observer: Observer) -> None:
96
+ """Bind the observer whose ``on_model`` receives each call's usage."""
97
+ self._observer = observer
98
+
99
+ def _emit(self, kwargs: Any, response_obj: Any, start_time: Any, end_time: Any) -> None:
100
+ """Stamp usage and the replay-capture attributes onto the model span; swallow any failure.
101
+
102
+ Usage (tokens/cost/latency) always lands; the record/replay attributes (request hash, and
103
+ the request/response payload only when the observer's ``capture_content`` is set) let P12
104
+ rebuild a deterministic cassette (§4.10). Both go on the same active model span.
105
+ """
106
+ try:
107
+ kwargs = kwargs or {}
108
+ usage = usage_from_litellm(kwargs, response_obj, start_time, end_time)
109
+ if usage is not None:
110
+ self._observer.on_model(usage)
111
+ capture_content = bool(getattr(self._observer, "capture_content", False))
112
+ self._observer.annotate_model(
113
+ replay_attributes(kwargs, response_obj, capture_content=capture_content)
114
+ )
115
+ except Exception: # noqa: BLE001 - a callback must never break the model call
116
+ _log.warning("observability.litellm_logger.failed (usage dropped)", exc_info=True)
117
+
118
+ def log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
119
+ """LiteLLM sync success hook — stamp usage onto the current model span."""
120
+ self._emit(kwargs, response_obj, start_time, end_time)
121
+
122
+ async def async_log_success_event(self, kwargs, response_obj, start_time, end_time) -> None:
123
+ """LiteLLM async success hook — stamp usage onto the current model span."""
124
+ self._emit(kwargs, response_obj, start_time, end_time)
125
+
126
+
127
+ #: The one registered logger for this process; ``None`` until the first registration.
128
+ _registered: AgentShipLiteLLMLogger | None = None
129
+
130
+
131
+ def register_litellm_logger(observer: Observer) -> AgentShipLiteLLMLogger:
132
+ """Register the process-global LiteLLM callback once, returning it (idempotent).
133
+
134
+ On the first call, an :class:`AgentShipLiteLLMLogger` is appended to ``litellm.callbacks``.
135
+ Later calls return the same logger without re-registering, so only one usage stamp fires per
136
+ model call regardless of how many agents are built.
137
+ """
138
+ global _registered
139
+ if _registered is not None:
140
+ return _registered
141
+ logger = AgentShipLiteLLMLogger(observer)
142
+ litellm.callbacks.append(logger)
143
+ _registered = logger
144
+ return logger
145
+
146
+
147
+ def _reset_litellm_logger_for_tests() -> None:
148
+ """Remove the registered callback and clear the guard. For registration tests only."""
149
+ global _registered
150
+ if _registered is not None and _registered in litellm.callbacks:
151
+ litellm.callbacks.remove(_registered)
152
+ _registered = None
@@ -0,0 +1,8 @@
1
+ """The OpenTelemetry implementation of the ``Observer`` port: kinds mapping + ``OTelObserver``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .kinds import to_otel_kind
6
+ from .observer import OTelObserver
7
+
8
+ __all__ = ["OTelObserver", "to_otel_kind"]
@@ -0,0 +1,26 @@
1
+ """Map AgentShip's semantic :class:`SpanKind` onto OpenTelemetry's ``trace.SpanKind`` (§4.1).
2
+
3
+ OTel has no ``AGENT`` or ``TOOL`` kind, so the semantic role travels on the
4
+ ``gen_ai.operation.name`` attribute (where Phoenix/Langfuse's OpenInference expects it) and the OTel
5
+ kind is only a coarse client/internal hint. The mapping: ``LLM`` → ``CLIENT`` (an outbound call to a
6
+ provider), everything else (``AGENT``/``TOOL``/``INTERNAL``) → ``INTERNAL``. Kept in one tiny table
7
+ so the choice is inspectable in one place.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from agentship.observability import SpanKind
13
+ from opentelemetry.trace import SpanKind as OTelSpanKind
14
+
15
+ #: The single source of truth for AgentShip-kind → OTel-kind. ``LLM`` is the only outbound call.
16
+ _KIND_TO_OTEL: dict[SpanKind, OTelSpanKind] = {
17
+ SpanKind.AGENT: OTelSpanKind.INTERNAL,
18
+ SpanKind.LLM: OTelSpanKind.CLIENT,
19
+ SpanKind.TOOL: OTelSpanKind.INTERNAL,
20
+ SpanKind.INTERNAL: OTelSpanKind.INTERNAL,
21
+ }
22
+
23
+
24
+ def to_otel_kind(kind: SpanKind) -> OTelSpanKind:
25
+ """Return the OTel ``SpanKind`` for an AgentShip ``SpanKind`` (defaults to ``INTERNAL``)."""
26
+ return _KIND_TO_OTEL.get(kind, OTelSpanKind.INTERNAL)
@@ -0,0 +1,224 @@
1
+ """``OTelObserver`` — the concrete :class:`Observer` over an OpenTelemetry ``TracerProvider`` (C1).
2
+
3
+ This is the default observer. It owns the span-tree shape (§4.1): every ``span()`` opens an OTel
4
+ span set as the current one, so a span opened inside another nests automatically (OTel's own
5
+ contextvars), and the tree is identical whichever engine runs. It stamps GenAI attributes (§4.2)
6
+ and, via :meth:`on_model`, rolls up cost/tokens/latency onto the active ``model`` span.
7
+
8
+ Fail-open is a hard rule (§4.7): a tracing/SDK fault is logged and swallowed so it can never break
9
+ an agent run. Only the *caller's* exception propagates — and when it does, the span records it and
10
+ is marked errored before it re-raises, so the run's error flow is untouched.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from collections.abc import Iterator, Mapping
17
+ from contextlib import contextmanager
18
+ from typing import Any
19
+
20
+ from agentship.observability import Observer, Span, SpanKind, Usage, semconv, usage_attributes
21
+ from opentelemetry import trace
22
+ from opentelemetry.trace import Span as OTelSpanType
23
+ from opentelemetry.trace import SpanKind as OTelSpanKind
24
+ from opentelemetry.trace import Status, StatusCode
25
+
26
+ from .kinds import to_otel_kind
27
+
28
+ _log = logging.getLogger("agentship.observability")
29
+
30
+ #: OTel accepts only str/bool/int/float (and sequences of those) as attribute values.
31
+ _SCALAR = (str, bool, int, float)
32
+
33
+
34
+ def _clean_attrs(attrs: Mapping[str, Any] | None) -> dict[str, Any]:
35
+ """Coerce an attribute mapping into OTel-safe values, dropping ``None``.
36
+
37
+ Anything not a scalar (or a list of scalars) is stringified rather than dropped, so a caller's
38
+ attribute is never silently lost — it just arrives as text. ``None`` values are omitted because
39
+ OTel rejects them.
40
+ """
41
+ cleaned: dict[str, Any] = {}
42
+ for key, value in (attrs or {}).items():
43
+ if value is None:
44
+ continue
45
+ if isinstance(value, _SCALAR):
46
+ cleaned[key] = value
47
+ elif isinstance(value, (list, tuple)) and all(isinstance(v, _SCALAR) for v in value):
48
+ cleaned[key] = list(value)
49
+ else:
50
+ cleaned[key] = str(value)
51
+ return cleaned
52
+
53
+
54
+ class _OTelSpan:
55
+ """The :class:`Span` handed to callers — a thin writer over a live OTel span."""
56
+
57
+ def __init__(self, span: OTelSpanType) -> None:
58
+ """Bind the OTel span this handle writes through."""
59
+ self._span = span
60
+
61
+ def set_attribute(self, key: str, value: Any) -> None:
62
+ """Set one attribute, coercing it to an OTel-safe value first."""
63
+ cleaned = _clean_attrs({key: value})
64
+ if cleaned:
65
+ self._span.set_attribute(key, cleaned[key])
66
+
67
+ def set_attributes(self, attrs: Mapping[str, Any]) -> None:
68
+ """Set several attributes at once (each coerced, ``None`` dropped)."""
69
+ self._span.set_attributes(_clean_attrs(attrs))
70
+
71
+ def record_exception(self, exc: BaseException) -> None:
72
+ """Attach the exception to the span (does not itself set error status)."""
73
+ self._span.record_exception(exc)
74
+
75
+ def set_error(self, exc: BaseException | None = None) -> None:
76
+ """Mark the span's status ERROR, recording ``exc`` when given."""
77
+ if exc is not None:
78
+ self._span.record_exception(exc)
79
+ self._span.set_status(Status(StatusCode.ERROR))
80
+
81
+ def end(self) -> None:
82
+ """End the underlying OTel span, fixing its end time (for :meth:`Observer.start_span`)."""
83
+ self._span.end()
84
+
85
+
86
+ class _NoOpSpan:
87
+ """Handed out only when opening a real span itself failed — keeps the caller's block running."""
88
+
89
+ def set_attribute(self, key: str, value: Any) -> None:
90
+ """Ignore."""
91
+
92
+ def set_attributes(self, attrs: Mapping[str, Any]) -> None:
93
+ """Ignore."""
94
+
95
+ def record_exception(self, exc: BaseException) -> None:
96
+ """Ignore."""
97
+
98
+ def set_error(self, exc: BaseException | None = None) -> None:
99
+ """Ignore."""
100
+
101
+ def end(self) -> None:
102
+ """Ignore — a no-op span has nothing to close."""
103
+
104
+
105
+ class OTelObserver(Observer):
106
+ """The OpenTelemetry :class:`Observer`: a span factory + per-model-call usage sink.
107
+
108
+ Construct with a configured ``TracerProvider`` (the factory builds one process-global provider).
109
+ ``capture_content`` mirrors the PHI gate — when false, content-bearing attributes are never set,
110
+ so prompt/response text cannot leave the process even if a caller passes it.
111
+ """
112
+
113
+ def __init__(
114
+ self, tracer_provider: trace.TracerProvider, *, capture_content: bool = False
115
+ ) -> None:
116
+ """Bind a tracer from ``tracer_provider`` and record the PHI content-capture flag."""
117
+ self._tracer = tracer_provider.get_tracer("agentship")
118
+ self.capture_content = capture_content
119
+
120
+ @contextmanager
121
+ def span(
122
+ self, name: str, kind: SpanKind, attrs: Mapping[str, Any] | None = None
123
+ ) -> Iterator[Span]:
124
+ """Open an OTel span ``name`` of ``kind`` (nested under the current one) and yield a handle.
125
+
126
+ On a raise inside the block, OTel records the exception and sets ERROR status before it
127
+ re-raises. If opening the span itself fails, a no-op handle is yielded so the caller's work
128
+ still runs (fail-open, §4.7).
129
+ """
130
+ try:
131
+ cm = self._tracer.start_as_current_span(
132
+ name,
133
+ kind=to_otel_kind(kind),
134
+ attributes=_clean_attrs(attrs),
135
+ record_exception=True,
136
+ set_status_on_exception=True,
137
+ )
138
+ except Exception: # noqa: BLE001 - tracing must never break the run
139
+ _log.warning("observability.span.failed name=%s (tracing skipped)", name, exc_info=True)
140
+ yield _NoOpSpan()
141
+ return
142
+ with cm as otel_span:
143
+ yield _OTelSpan(otel_span)
144
+
145
+ def start_span(
146
+ self,
147
+ name: str,
148
+ kind: SpanKind,
149
+ attrs: Mapping[str, Any] | None = None,
150
+ *,
151
+ parent: Span | None = None,
152
+ ) -> Span:
153
+ """Open an OTel span without entering it as current; the caller must :meth:`Span.end` it.
154
+
155
+ The manual counterpart to :meth:`span`, for the callback-driven tracer. ``parent`` nests the
156
+ span explicitly: a :class:`_OTelSpan` from an earlier ``start_span`` becomes the parent via
157
+ its OTel context, so the model→tool tree is rebuilt from ``run_id``/``parent_run_id`` even
158
+ though the events fire as separate calls. With ``parent=None`` the ambient context is used,
159
+ so the first callback span lands under the open root ``agent`` span. Fail-open: if opening
160
+ fails, a no-op handle is returned so the run continues untraced (§4.7).
161
+ """
162
+ try:
163
+ context = None
164
+ if isinstance(parent, _OTelSpan):
165
+ context = trace.set_span_in_context(parent._span)
166
+ otel_span = self._tracer.start_span(
167
+ name,
168
+ context=context,
169
+ kind=to_otel_kind(kind),
170
+ attributes=_clean_attrs(attrs),
171
+ )
172
+ return _OTelSpan(otel_span)
173
+ except Exception: # noqa: BLE001 - tracing must never break the run
174
+ _log.warning(
175
+ "observability.start_span.failed name=%s (tracing skipped)", name, exc_info=True
176
+ )
177
+ return _NoOpSpan()
178
+
179
+ def _is_model_span(self, span: OTelSpanType) -> bool:
180
+ """Return whether ``span`` is the ``model`` span usage should land on.
181
+
182
+ Prefers the span name (``model``); falls back to the OTel kind (``CLIENT``, which only the
183
+ model span uses) for spans whose name is not exposed.
184
+ """
185
+ if getattr(span, "name", None) == semconv.SPAN_MODEL:
186
+ return True
187
+ return getattr(span, "kind", None) == OTelSpanKind.CLIENT
188
+
189
+ def on_model(self, usage: Usage) -> None:
190
+ """Stamp GenAI + OpenInference cost/token/latency attributes onto the active model span.
191
+
192
+ A no-op (with a warning) when the current span is not a model span, so a stray callback
193
+ never corrupts an unrelated span (§4.3). Fail-open — any error here is swallowed.
194
+ """
195
+ try:
196
+ span = trace.get_current_span()
197
+ if not span.is_recording() or not self._is_model_span(span):
198
+ _log.warning("on_model called off a model span; usage dropped")
199
+ return
200
+ span.set_attributes(_clean_attrs(usage_attributes(usage)))
201
+ except Exception: # noqa: BLE001 - tracing must never break the run
202
+ _log.warning("observability.on_model.failed (usage dropped)", exc_info=True)
203
+
204
+ def annotate_model(self, attrs: Mapping[str, Any]) -> None:
205
+ """Stamp extra attributes (replay hash + gated payload) onto the active model span.
206
+
207
+ A no-op (with a warning) off a model span, mirroring :meth:`on_model`. Fail-open — any
208
+ error is swallowed so the capture hook can never break the model call (§4.7).
209
+ """
210
+ try:
211
+ span = trace.get_current_span()
212
+ if not span.is_recording() or not self._is_model_span(span):
213
+ _log.warning("annotate_model called off a model span; attributes dropped")
214
+ return
215
+ span.set_attributes(_clean_attrs(attrs))
216
+ except Exception: # noqa: BLE001 - tracing must never break the run
217
+ _log.warning("observability.annotate_model.failed (attributes dropped)", exc_info=True)
218
+
219
+ def current_trace_id(self) -> str | None:
220
+ """Return the active trace id as 32-hex, or ``None`` when no valid span is active."""
221
+ ctx = trace.get_current_span().get_span_context()
222
+ if ctx is None or not ctx.is_valid:
223
+ return None
224
+ return format(ctx.trace_id, "032x")
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentship-observability
3
+ Version: 0.0.1
4
+ Summary: AgentShip observability — the OpenTelemetry implementation of the Observer port: span-tree exporter pipeline (Phoenix/Langfuse/LangSmith), the one-per-process LiteLLM cost/token callback, and the studio launcher. The vendor-free contract lives in agentship-core.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: agentship-core==0.0.1
8
+ Requires-Dist: litellm>=1.74
9
+ Requires-Dist: opentelemetry-api>=1.27
10
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27
11
+ Requires-Dist: opentelemetry-sdk>=1.27
12
+ Provides-Extra: phoenix
13
+ Requires-Dist: arize-phoenix-otel>=0.6; extra == 'phoenix'
14
+ Requires-Dist: openinference-semantic-conventions>=0.1; extra == 'phoenix'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # agentship-observability
18
+
19
+ The OpenTelemetry implementation of AgentShip's `Observer` port (Phase 07).
20
+
21
+ The **contract** — the `Observer`/`Span` port, `SpanKind`/`Usage`, the frozen SEMCONV span-name and
22
+ attribute-key constants, and the `TraceView` read-port — lives in **`agentship-core`** with no OTel
23
+ dependency, so an engine or eval hook can read a trace with only the kernel installed (design §4.6).
24
+
25
+ This package holds only the **pipeline**:
26
+
27
+ - `otel/observer.py` — `OTelObserver`, the concrete port over a `TracerProvider`: it builds the
28
+ canonical span tree (§4.1), sets GenAI attributes (§4.2), and rolls up cost/tokens/latency.
29
+ - `exporters/` — one `SpanProcessor` per backend (`console`, `phoenix`, `langfuse`, `langsmith`),
30
+ all reached over OTLP/HTTP (no per-vendor SDK lock-in).
31
+ - `litellm_callback.py` — one process-global `CustomLogger` that stamps cost/tokens/latency onto the
32
+ current `model` span, so every LiteLLM caller is covered without per-call wiring.
33
+ - `config.py` + `factory.py` — the `observability:` config surface and the process-global
34
+ `TracerProvider` the factory memoises; `provider: otel` (default) → `OTelObserver`, `none` → NoOp.
35
+ - `capture.py` — the record/replay hook that stamps `agentship.replay.request_hash` (+ gated
36
+ request/response) so P12 can build deterministic cassettes.
37
+ - `studio.py` — `generate_langgraph_json` + the loopback-only, dev-token studio launcher.
38
+
39
+ Everything a price table, trace store, or UI would provide is **consumed** (Phoenix/Langfuse/
40
+ LangSmith + LangGraph Studio); we build only the composition.
41
+
42
+ ## Conformance guards (we keep thin, but prove it conforms)
43
+
44
+ Two pieces are deliberately vendor-free — the semantic-convention key constants and the
45
+ `RecordingObserver` — because the kernel cannot import OpenTelemetry (design §4.6) and the base
46
+ install ships without a collector. To keep them from silently drifting from the real thing, each is
47
+ continuously proven against upstream:
48
+
49
+ - `tests/test_semconv_upstream.py` asserts every `gen_ai.*` / `llm.*` key equals the OTel-GenAI /
50
+ OpenInference string it claims to speak.
51
+ - `tests/test_observer_parity.py` runs one interaction through both the in-memory `RecordingObserver`
52
+ and the real `OTelObserver`, and asserts the two produce the same span tree and model-span
53
+ attributes.
54
+
55
+ See [`docs/decisions/0001-integrate-not-invent.md`](../../docs/decisions/0001-integrate-not-invent.md)
56
+ for the full rationale.
@@ -0,0 +1,18 @@
1
+ agentship_observability/__init__.py,sha256=CK6SEAOPawrbE01Bmt8gAEgPjq6kZ9B5lfetHNYPi30,620
2
+ agentship_observability/config.py,sha256=HG1yRkKWFUYY740o-ndAxuPrJuOzU5PLuk0bP9ZNZXc,2889
3
+ agentship_observability/factory.py,sha256=GwhqmqOl4f8rphCyl_k5uZ3GOJF1nyB8YnuAC5jVLlA,5479
4
+ agentship_observability/litellm_logger.py,sha256=4HM7DOQ3_4-DarNWPRhBQQWenpDcef9uoUj8n5Xshu4,7002
5
+ agentship_observability/exporters/__init__.py,sha256=KVAr8uOUmHkZSo82aQgz6eVZppss2Wll5XNbH1Dl-1M,521
6
+ agentship_observability/exporters/console.py,sha256=oKetjrodGNKz0wBT1RmwYEVY6asYyYK_-0S2S41VB5A,1010
7
+ agentship_observability/exporters/factory.py,sha256=S65eWOKP0TC9D-Y6VyJGlaF1aANos_o6Ylh_qGMxGkQ,1506
8
+ agentship_observability/exporters/langfuse.py,sha256=lIE0YvZY5piG2JhhhzWikCZNkUqwz2MK3EumTsMCu3c,1849
9
+ agentship_observability/exporters/langsmith.py,sha256=MG1lh4gUFxDl24oTjnWZOrsIOqtoZwoKooDpxfeBNoI,1459
10
+ agentship_observability/exporters/opik.py,sha256=tG7Oe6qJxR0Wsw-gmzwCkJjjAz3jYbX-rq08tN1f-0c,2245
11
+ agentship_observability/exporters/phoenix.py,sha256=nOR_QLYsluPsj7d_W8jTYHxPzkiD1MSorjzX-Cc2NVU,1603
12
+ agentship_observability/otel/__init__.py,sha256=zDJRxp5gfIm5Qi7YAFF6Q17ToHsHMPkG7UZFTSdgVfg,247
13
+ agentship_observability/otel/kinds.py,sha256=Qoo6x74wzxEOHBNJdYPSMpJKbsBLyocjgy-xMk2hLzs,1191
14
+ agentship_observability/otel/observer.py,sha256=OPyIhUAuIAj3cQ0ViK1nC3_r-U-PA7gQYQ_i8m8ZQCA,9803
15
+ agentship_observability-0.0.1.dist-info/METADATA,sha256=9Lf002sgks4Jy5FCD8-BPzAR4Y54rHZ-NOcPO0RPfVM,3263
16
+ agentship_observability-0.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
17
+ agentship_observability-0.0.1.dist-info/entry_points.txt,sha256=O-csEoe8xy_SK_y02zUKdjC4EzKnHYfqnken1__fAeE,81
18
+ agentship_observability-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [agentship.observers]
2
+ otel = agentship_observability.factory:build_otel_observer