anytrace 0.9.0__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.
anytrace/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """anytrace — vendor-neutral agent tracing over OpenTelemetry.
2
+
3
+ Public API surface is only what is exported here. Keep it small.
4
+ """
5
+
6
+ from anytrace.context import extract_context, inject_context, use_context
7
+ from anytrace.core import (
8
+ add_event,
9
+ add_metadata,
10
+ add_tags,
11
+ init,
12
+ instrument_logging,
13
+ record_generation,
14
+ set_io,
15
+ set_session,
16
+ set_user,
17
+ shutdown,
18
+ stats,
19
+ trace_agent,
20
+ trace_chain,
21
+ trace_embedding,
22
+ trace_retriever,
23
+ trace_span,
24
+ trace_tool,
25
+ traced,
26
+ )
27
+
28
+ __all__ = [
29
+ "add_event",
30
+ "add_metadata",
31
+ "add_tags",
32
+ "extract_context",
33
+ "init",
34
+ "inject_context",
35
+ "instrument_logging",
36
+ "record_generation",
37
+ "set_io",
38
+ "set_session",
39
+ "set_user",
40
+ "shutdown",
41
+ "stats",
42
+ "trace_agent",
43
+ "trace_chain",
44
+ "trace_embedding",
45
+ "trace_retriever",
46
+ "trace_span",
47
+ "trace_tool",
48
+ "traced",
49
+ "use_context",
50
+ ]
@@ -0,0 +1,33 @@
1
+ """Backend adapters: per-backend OTLP endpoint, auth headers, and attribute mapping."""
2
+
3
+ from anytrace.adapters.base import BackendAdapter
4
+ from anytrace.config import ConfigError
5
+
6
+ __all__ = ["BackendAdapter", "get_adapter", "register_adapter"]
7
+
8
+ _REGISTRY: dict[str, BackendAdapter] = {}
9
+
10
+
11
+ def register_adapter(adapter: BackendAdapter) -> None:
12
+ _REGISTRY[adapter.name] = adapter
13
+
14
+
15
+ def get_adapter(kind: str) -> BackendAdapter:
16
+ try:
17
+ return _REGISTRY[kind]
18
+ except KeyError:
19
+ raise ConfigError(
20
+ f"no adapter registered for backend kind {kind!r}; "
21
+ f"registered kinds: {sorted(_REGISTRY) or 'none'}"
22
+ ) from None
23
+
24
+
25
+ from anytrace.adapters.langfuse import LangfuseAdapter # noqa: E402
26
+ from anytrace.adapters.langsmith import LangSmithAdapter # noqa: E402
27
+ from anytrace.adapters.otlp import OTLPAdapter # noqa: E402
28
+ from anytrace.adapters.phoenix import PhoenixAdapter # noqa: E402
29
+
30
+ register_adapter(LangfuseAdapter())
31
+ register_adapter(LangSmithAdapter())
32
+ register_adapter(PhoenixAdapter())
33
+ register_adapter(OTLPAdapter())
@@ -0,0 +1,36 @@
1
+ """Adapter interface every backend must implement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, Protocol, runtime_checkable
7
+
8
+ from anytrace.config import BackendConfig as Config
9
+
10
+ Attributes = Mapping[str, Any]
11
+
12
+
13
+ @runtime_checkable
14
+ class BackendAdapter(Protocol):
15
+ """A tracing backend reachable via OTLP.
16
+
17
+ Adapters carry no vendor SDK dependencies: they only supply the OTLP
18
+ endpoint, auth headers, and a GenAI -> backend attribute mapping.
19
+ """
20
+
21
+ @property
22
+ def name(self) -> str:
23
+ """Stable identifier used in configuration (e.g. "langsmith", "langfuse")."""
24
+ ...
25
+
26
+ def endpoint(self, cfg: Config) -> str:
27
+ """OTLP HTTP endpoint URL for this backend."""
28
+ ...
29
+
30
+ def headers(self, cfg: Config) -> dict[str, str]:
31
+ """Auth and transport headers for the OTLP exporter."""
32
+ ...
33
+
34
+ def map_attributes(self, attrs: Attributes) -> dict[str, Any]:
35
+ """Translate GenAI semantic-convention attributes to backend-specific ones."""
36
+ ...
@@ -0,0 +1,80 @@
1
+ """Langfuse adapter: OTLP endpoint, Basic auth, and attribute mapping.
2
+
3
+ Verified against https://langfuse.com/docs/opentelemetry/get-started (2026-07):
4
+ OTLP traces endpoint is ``{LANGFUSE_ENDPOINT}/api/public/otel/v1/traces``, auth is
5
+ HTTP Basic with ``public_key:secret_key``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from typing import Any
12
+
13
+ from anytrace.adapters.base import Attributes
14
+ from anytrace.config import BackendConfig
15
+
16
+ _OTLP_PATH = "/api/public/otel/v1/traces"
17
+
18
+ # The FULL GenAI -> Langfuse mapping lives in this one table.
19
+ # Langfuse understands gen_ai.* natively, so originals are kept as-is;
20
+ # these entries ADD the Langfuse-native name alongside the original.
21
+ _ATTRIBUTE_MAP: dict[str, str] = {
22
+ "user.id": "langfuse.user.id",
23
+ "session.id": "langfuse.session.id",
24
+ "anytrace.release": "langfuse.release",
25
+ "anytrace.version": "langfuse.version",
26
+ }
27
+
28
+ # gen_ai.operation.name -> langfuse.observation.type. Falls back to the model
29
+ # marker. Langfuse supports tool/retriever/embedding/agent/... types since v3.
30
+ _OPERATION_TYPES: dict[str, str] = {
31
+ "chat": "generation",
32
+ "completion": "generation",
33
+ "execute_tool": "tool",
34
+ "retrieval": "retriever",
35
+ "embeddings": "embedding",
36
+ "invoke_agent": "agent",
37
+ "chain": "chain",
38
+ }
39
+
40
+ # Spans carrying this attribute are LLM calls and get typed as generations.
41
+ _GENERATION_MARKER = "gen_ai.request.model"
42
+
43
+ # Custom attributes (add_metadata / trace_span kwargs) only become first-class
44
+ # Langfuse metadata when prefixed; mirror anything outside these namespaces
45
+ # into langfuse.observation.metadata.<key> (audit 2026-07-15).
46
+ _PASSTHROUGH_PREFIXES = ("gen_ai.", "langfuse.", "llm.", "http.", "url.", "anytrace.")
47
+
48
+
49
+ class LangfuseAdapter:
50
+ name = "langfuse"
51
+
52
+ def endpoint(self, cfg: BackendConfig) -> str:
53
+ base = cfg.endpoint.rstrip("/")
54
+ if base.endswith(_OTLP_PATH):
55
+ return base
56
+ return base + _OTLP_PATH
57
+
58
+ def headers(self, cfg: BackendConfig) -> dict[str, str]:
59
+ credentials = f"{cfg.auth['public_key']}:{cfg.auth['secret_key']}"
60
+ encoded = base64.b64encode(credentials.encode()).decode()
61
+ return {"Authorization": f"Basic {encoded}"}
62
+
63
+ def map_attributes(self, attrs: Attributes) -> dict[str, Any]:
64
+ mapped = dict(attrs)
65
+ for source, target in _ATTRIBUTE_MAP.items():
66
+ if source in mapped:
67
+ mapped[target] = mapped[source]
68
+ for key, value in attrs.items():
69
+ if key in _ATTRIBUTE_MAP or key.startswith(_PASSTHROUGH_PREFIXES):
70
+ continue
71
+ mapped[f"langfuse.observation.metadata.{key}"] = value
72
+ tags = mapped.get("anytrace.tags")
73
+ if tags:
74
+ mapped["langfuse.trace.tags"] = list(tags)
75
+ operation = mapped.get("gen_ai.operation.name")
76
+ if operation in _OPERATION_TYPES:
77
+ mapped["langfuse.observation.type"] = _OPERATION_TYPES[operation]
78
+ elif _GENERATION_MARKER in mapped:
79
+ mapped["langfuse.observation.type"] = "generation"
80
+ return mapped
@@ -0,0 +1,88 @@
1
+ """LangSmith adapter: OTLP endpoint, x-api-key auth, and attribute mapping.
2
+
3
+ Verified against https://docs.langchain.com/langsmith/trace-with-opentelemetry (2026-07):
4
+ cloud OTLP endpoint is ``{base}/otel/v1/traces``; self-hosted appends ``/api/v1/otel``
5
+ to the base URL (so the full traces path is ``{base}/api/v1/otel/v1/traces``).
6
+ LangSmith natively understands ``gen_ai.*`` including ``gen_ai.usage.input_tokens`` /
7
+ ``output_tokens``, so no OpenLLMetry renaming is needed — originals are preserved.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from anytrace.adapters.base import Attributes
15
+ from anytrace.config import BackendConfig
16
+
17
+ _CLOUD_HOSTS = ("api.smith.langchain.com",)
18
+ _TRACES_SUFFIX = "/v1/traces"
19
+
20
+ # The FULL GenAI -> LangSmith mapping lives in this one table.
21
+ # gen_ai.* passes through untouched; these entries ADD LangSmith-native names.
22
+ _ATTRIBUTE_MAP: dict[str, str] = {
23
+ "user.id": "langsmith.metadata.user_id",
24
+ "session.id": "langsmith.metadata.session_id",
25
+ # LangSmith has no cost attribute (it computes its own from model pricing);
26
+ # mirror the reported value into metadata so it isn't lost (audit 2026-07-15).
27
+ "gen_ai.usage.cost": "langsmith.metadata.cost",
28
+ "anytrace.release": "langsmith.metadata.release",
29
+ "anytrace.version": "langsmith.metadata.version",
30
+ }
31
+
32
+ # gen_ai.operation.name -> langsmith.span.kind. Falls back to the model marker.
33
+ _OPERATION_KINDS: dict[str, str] = {
34
+ "chat": "llm",
35
+ "completion": "llm",
36
+ "execute_tool": "tool",
37
+ "retrieval": "retriever",
38
+ "embeddings": "embedding",
39
+ "invoke_agent": "chain", # LangSmith has no agent run type
40
+ "chain": "chain",
41
+ }
42
+
43
+ # Spans carrying this attribute are LLM calls and get langsmith.span.kind = "llm".
44
+ _LLM_MARKER = "gen_ai.request.model"
45
+
46
+ # Custom attributes (add_metadata / trace_span kwargs) are invisible to LangSmith
47
+ # unless prefixed; anything outside these namespaces gets mirrored into
48
+ # langsmith.metadata.<key> (audit 2026-07-15: status/eta_days/order_id were dropped).
49
+ _PASSTHROUGH_PREFIXES = ("gen_ai.", "langsmith.", "llm.", "http.", "url.", "anytrace.")
50
+
51
+
52
+ class LangSmithAdapter:
53
+ name = "langsmith"
54
+
55
+ def endpoint(self, cfg: BackendConfig) -> str:
56
+ base = cfg.endpoint.rstrip("/")
57
+ if "/otel" in base:
58
+ # Caller already supplied the OTLP base; just ensure the traces path.
59
+ return base if base.endswith(_TRACES_SUFFIX) else base + _TRACES_SUFFIX
60
+ if any(host in base for host in _CLOUD_HOSTS):
61
+ return base + "/otel" + _TRACES_SUFFIX
62
+ return base + "/api/v1/otel" + _TRACES_SUFFIX
63
+
64
+ def headers(self, cfg: BackendConfig) -> dict[str, str]:
65
+ headers = {"x-api-key": cfg.auth["api_key"]}
66
+ project = cfg.options.get("project")
67
+ if project:
68
+ headers["Langsmith-Project"] = project
69
+ return headers
70
+
71
+ def map_attributes(self, attrs: Attributes) -> dict[str, Any]:
72
+ mapped = dict(attrs)
73
+ for source, target in _ATTRIBUTE_MAP.items():
74
+ if source in mapped:
75
+ mapped[target] = mapped[source]
76
+ for key, value in attrs.items():
77
+ if key in _ATTRIBUTE_MAP or key.startswith(_PASSTHROUGH_PREFIXES):
78
+ continue
79
+ mapped[f"langsmith.metadata.{key}"] = value
80
+ tags = mapped.get("anytrace.tags")
81
+ if tags:
82
+ mapped["langsmith.span.tags"] = ",".join(str(t) for t in tags)
83
+ operation = mapped.get("gen_ai.operation.name")
84
+ if operation in _OPERATION_KINDS:
85
+ mapped["langsmith.span.kind"] = _OPERATION_KINDS[operation]
86
+ elif _LLM_MARKER in mapped:
87
+ mapped["langsmith.span.kind"] = "llm"
88
+ return mapped
@@ -0,0 +1,36 @@
1
+ """Generic OTLP adapter: any collector or OTLP-native receiver, no dialect.
2
+
3
+ Attributes pass through unchanged (neutral ``gen_ai.*`` / ``anytrace.*``) —
4
+ when routing through an OTel Collector to a vendor, do vendor mapping in the
5
+ collector, or configure that vendor's anytrace adapter directly instead.
6
+ Optional headers (e.g. collector auth) come from ``OTLP_HEADERS="k=v,k2=v2"``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from anytrace.adapters.base import Attributes
14
+ from anytrace.config import BackendConfig
15
+
16
+ _OTLP_PATH = "/v1/traces"
17
+
18
+
19
+ class OTLPAdapter:
20
+ name = "otlp"
21
+
22
+ def endpoint(self, cfg: BackendConfig) -> str:
23
+ base = cfg.endpoint.rstrip("/")
24
+ return base if base.endswith(_OTLP_PATH) else base + _OTLP_PATH
25
+
26
+ def headers(self, cfg: BackendConfig) -> dict[str, str]:
27
+ raw = cfg.options.get("headers", "")
28
+ headers: dict[str, str] = {}
29
+ for pair in raw.split(","):
30
+ if "=" in pair:
31
+ key, _, value = pair.partition("=")
32
+ headers[key.strip()] = value.strip()
33
+ return headers
34
+
35
+ def map_attributes(self, attrs: Attributes) -> dict[str, Any]:
36
+ return dict(attrs)
@@ -0,0 +1,71 @@
1
+ """Arize Phoenix adapter: OTLP endpoint and OpenInference attribute mapping.
2
+
3
+ Phoenix (OSS) ingests OTLP at ``{PHOENIX_ENDPOINT}/v1/traces`` — no auth for
4
+ self-hosted; Phoenix Cloud uses an ``api_key`` header (PHOENIX_API_KEY).
5
+ Phoenix's native dialect is OpenInference, so the mapping mirrors GenAI
6
+ attributes into OpenInference names; originals are preserved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from anytrace.adapters.base import Attributes
14
+ from anytrace.config import BackendConfig
15
+
16
+ _OTLP_PATH = "/v1/traces"
17
+
18
+ # The FULL GenAI -> OpenInference mapping lives in these two tables.
19
+ _ATTRIBUTE_MAP: dict[str, str] = {
20
+ "gen_ai.request.model": "llm.model_name",
21
+ "gen_ai.usage.input_tokens": "llm.token_count.prompt",
22
+ "gen_ai.usage.output_tokens": "llm.token_count.completion",
23
+ "gen_ai.prompt": "input.value",
24
+ "gen_ai.completion": "output.value",
25
+ # user.id / session.id are OpenInference-native already — passed through.
26
+ }
27
+
28
+ # gen_ai.operation.name -> openinference.span.kind (values are UPPERCASE).
29
+ _OPERATION_KINDS: dict[str, str] = {
30
+ "chat": "LLM",
31
+ "completion": "LLM",
32
+ "execute_tool": "TOOL",
33
+ "retrieval": "RETRIEVER",
34
+ "embeddings": "EMBEDDING",
35
+ "invoke_agent": "AGENT",
36
+ "chain": "CHAIN",
37
+ }
38
+
39
+ _LLM_MARKER = "gen_ai.request.model"
40
+
41
+
42
+ class PhoenixAdapter:
43
+ name = "phoenix"
44
+
45
+ def endpoint(self, cfg: BackendConfig) -> str:
46
+ base = cfg.endpoint.rstrip("/")
47
+ return base if base.endswith(_OTLP_PATH) else base + _OTLP_PATH
48
+
49
+ def headers(self, cfg: BackendConfig) -> dict[str, str]:
50
+ api_key = cfg.auth.get("api_key")
51
+ if not api_key:
52
+ return {}
53
+ # Phoenix Cloud (app.phoenix.arize.com) authenticates via Bearer
54
+ # (live-verified 2026-07-16: api_key header alone gets 401); the
55
+ # legacy api_key header is included for older self-hosted deployments.
56
+ return {"Authorization": f"Bearer {api_key}", "api_key": api_key}
57
+
58
+ def map_attributes(self, attrs: Attributes) -> dict[str, Any]:
59
+ mapped = dict(attrs)
60
+ for source, target in _ATTRIBUTE_MAP.items():
61
+ if source in mapped:
62
+ mapped[target] = mapped[source]
63
+ tags = mapped.get("anytrace.tags")
64
+ if tags:
65
+ mapped["tag.tags"] = list(tags)
66
+ operation = mapped.get("gen_ai.operation.name")
67
+ if operation in _OPERATION_KINDS:
68
+ mapped["openinference.span.kind"] = _OPERATION_KINDS[operation]
69
+ elif _LLM_MARKER in mapped:
70
+ mapped["openinference.span.kind"] = "LLM"
71
+ return mapped
@@ -0,0 +1,48 @@
1
+ """Exporter wrapper that applies an adapter's attribute mapping just before export.
2
+
3
+ One recorded span fans out to N backends; each backend's exporter sees the span
4
+ rewritten in its own attribute dialect, while the original span is untouched.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+
11
+ from opentelemetry.sdk.trace import ReadableSpan
12
+ from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
13
+
14
+ from anytrace.adapters.base import BackendAdapter
15
+
16
+
17
+ class MappingSpanExporter(SpanExporter):
18
+ """Delegates to ``wrapped`` after rewriting attributes via ``adapter.map_attributes``."""
19
+
20
+ def __init__(self, adapter: BackendAdapter, wrapped: SpanExporter) -> None:
21
+ self._adapter = adapter
22
+ self._wrapped = wrapped
23
+
24
+ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
25
+ return self._wrapped.export([self._map(span) for span in spans])
26
+
27
+ def _map(self, span: ReadableSpan) -> ReadableSpan:
28
+ mapped = self._adapter.map_attributes(dict(span.attributes or {}))
29
+ return ReadableSpan(
30
+ name=span.name,
31
+ context=span.get_span_context(),
32
+ parent=span.parent,
33
+ resource=span.resource,
34
+ attributes=mapped,
35
+ events=span.events,
36
+ links=span.links,
37
+ kind=span.kind,
38
+ status=span.status,
39
+ start_time=span.start_time,
40
+ end_time=span.end_time,
41
+ instrumentation_scope=span.instrumentation_scope,
42
+ )
43
+
44
+ def shutdown(self) -> None:
45
+ self._wrapped.shutdown()
46
+
47
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
48
+ return self._wrapped.force_flush(timeout_millis)
@@ -0,0 +1,10 @@
1
+ """Configuration loading: env vars first, optional YAML override, with validation."""
2
+
3
+ from anytrace.config.loader import (
4
+ BackendConfig,
5
+ ConfigError,
6
+ TracingConfig,
7
+ load_config,
8
+ )
9
+
10
+ __all__ = ["BackendConfig", "ConfigError", "TracingConfig", "load_config"]