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.
- agentship_observability/__init__.py +14 -0
- agentship_observability/config.py +57 -0
- agentship_observability/exporters/__init__.py +13 -0
- agentship_observability/exporters/console.py +24 -0
- agentship_observability/exporters/factory.py +38 -0
- agentship_observability/exporters/langfuse.py +41 -0
- agentship_observability/exporters/langsmith.py +32 -0
- agentship_observability/exporters/opik.py +43 -0
- agentship_observability/exporters/phoenix.py +32 -0
- agentship_observability/factory.py +122 -0
- agentship_observability/litellm_logger.py +152 -0
- agentship_observability/otel/__init__.py +8 -0
- agentship_observability/otel/kinds.py +26 -0
- agentship_observability/otel/observer.py +224 -0
- agentship_observability-0.0.1.dist-info/METADATA +56 -0
- agentship_observability-0.0.1.dist-info/RECORD +18 -0
- agentship_observability-0.0.1.dist-info/WHEEL +4 -0
- agentship_observability-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -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,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,,
|