softprobe 0.1.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.
- softprobe/__init__.py +54 -0
- softprobe/attributes.py +146 -0
- softprobe/client.py +324 -0
- softprobe/config.py +70 -0
- softprobe/identity.py +75 -0
- softprobe/langchain.py +529 -0
- softprobe/langchain_instrument.py +85 -0
- softprobe/normalize.py +60 -0
- softprobe/observation.py +540 -0
- softprobe/openai.py +371 -0
- softprobe/propagation.py +52 -0
- softprobe/redaction.py +88 -0
- softprobe/scores.py +102 -0
- softprobe/tools.py +251 -0
- softprobe/types.py +101 -0
- softprobe-0.1.0.dist-info/METADATA +70 -0
- softprobe-0.1.0.dist-info/RECORD +19 -0
- softprobe-0.1.0.dist-info/WHEEL +4 -0
- softprobe-0.1.0.dist-info/licenses/LICENSE +202 -0
softprobe/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Softprobe Python instrumentation SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from softprobe.client import SoftprobeClient
|
|
6
|
+
from softprobe.observation import Generation, Observation
|
|
7
|
+
from softprobe.redaction import default_redact_keys, redact_value
|
|
8
|
+
from softprobe.scores import build_score_request
|
|
9
|
+
from softprobe.tools import (
|
|
10
|
+
accumulate_tool_call_deltas,
|
|
11
|
+
finalize_tool_call_deltas,
|
|
12
|
+
normalize_tool_calls,
|
|
13
|
+
normalize_tool_definitions,
|
|
14
|
+
record_tool_calls,
|
|
15
|
+
record_tool_definitions,
|
|
16
|
+
tool_result_event_payload,
|
|
17
|
+
)
|
|
18
|
+
from softprobe.types import OBSERVATION_TYPES
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"OBSERVATION_TYPES",
|
|
22
|
+
"Generation",
|
|
23
|
+
"Observation",
|
|
24
|
+
"SoftprobeClient",
|
|
25
|
+
"accumulate_tool_call_deltas",
|
|
26
|
+
"build_score_request",
|
|
27
|
+
"default_redact_keys",
|
|
28
|
+
"finalize_tool_call_deltas",
|
|
29
|
+
"normalize_tool_calls",
|
|
30
|
+
"normalize_tool_definitions",
|
|
31
|
+
"propagate_attributes",
|
|
32
|
+
"record_tool_calls",
|
|
33
|
+
"record_tool_definitions",
|
|
34
|
+
"redact_value",
|
|
35
|
+
"tool_result_event_payload",
|
|
36
|
+
"observe_openai",
|
|
37
|
+
"CallbackHandler",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __getattr__(name: str) -> Any:
|
|
42
|
+
if name == "observe_openai":
|
|
43
|
+
from softprobe.openai import observe_openai
|
|
44
|
+
|
|
45
|
+
return observe_openai
|
|
46
|
+
if name == "propagate_attributes":
|
|
47
|
+
from softprobe.propagation import propagate_attributes
|
|
48
|
+
|
|
49
|
+
return propagate_attributes
|
|
50
|
+
if name == "CallbackHandler":
|
|
51
|
+
from softprobe.langchain import CallbackHandler
|
|
52
|
+
|
|
53
|
+
return CallbackHandler
|
|
54
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
softprobe/attributes.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping
|
|
4
|
+
|
|
5
|
+
from softprobe.redaction import serialize_captured
|
|
6
|
+
from softprobe.types import Attributes
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_metadata(attrs: Attributes, metadata: Mapping[str, Any] | None) -> None:
|
|
10
|
+
if not metadata:
|
|
11
|
+
return
|
|
12
|
+
for key, value in metadata.items():
|
|
13
|
+
if value is None:
|
|
14
|
+
continue
|
|
15
|
+
if isinstance(value, (str, int, float, bool)):
|
|
16
|
+
attrs[f"sp.metadata.{key}"] = value
|
|
17
|
+
else:
|
|
18
|
+
serialized = serialize_captured(value, [])
|
|
19
|
+
if serialized is not None:
|
|
20
|
+
attrs[f"sp.metadata.{key}"] = serialized
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_observation_attributes(
|
|
24
|
+
*,
|
|
25
|
+
observation_type: str,
|
|
26
|
+
attributes: Mapping[str, Any] | None = None,
|
|
27
|
+
input: Any | None = None,
|
|
28
|
+
output: Any | None = None,
|
|
29
|
+
session_id: str | None = None,
|
|
30
|
+
user_id: str | None = None,
|
|
31
|
+
release: str | None = None,
|
|
32
|
+
tags: list[str] | None = None,
|
|
33
|
+
metadata: Mapping[str, Any] | None = None,
|
|
34
|
+
version: str | None = None,
|
|
35
|
+
trace_name: str | None = None,
|
|
36
|
+
redact_keys: list[str],
|
|
37
|
+
) -> Attributes:
|
|
38
|
+
attrs: Attributes = dict(attributes or {})
|
|
39
|
+
attrs["sp.observation.type"] = observation_type
|
|
40
|
+
if session_id:
|
|
41
|
+
attrs["sp.session.id"] = session_id
|
|
42
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
43
|
+
if user_id:
|
|
44
|
+
attrs["sp.user.id"] = user_id
|
|
45
|
+
if release:
|
|
46
|
+
attrs["sp.release"] = release
|
|
47
|
+
if tags is not None:
|
|
48
|
+
attrs["sp.tags"] = list(tags)
|
|
49
|
+
if version:
|
|
50
|
+
attrs["sp.version"] = version
|
|
51
|
+
if trace_name:
|
|
52
|
+
attrs["sp.trace.name"] = trace_name
|
|
53
|
+
apply_metadata(attrs, metadata)
|
|
54
|
+
serialized_input = serialize_captured(input, redact_keys)
|
|
55
|
+
serialized_output = serialize_captured(output, redact_keys)
|
|
56
|
+
if serialized_input is not None:
|
|
57
|
+
attrs["sp.input"] = serialized_input
|
|
58
|
+
if serialized_output is not None:
|
|
59
|
+
attrs["sp.output"] = serialized_output
|
|
60
|
+
return attrs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_generation_attributes(
|
|
64
|
+
*,
|
|
65
|
+
attributes: Mapping[str, Any] | None = None,
|
|
66
|
+
input: Any | None = None,
|
|
67
|
+
output: Any | None = None,
|
|
68
|
+
session_id: str | None = None,
|
|
69
|
+
user_id: str | None = None,
|
|
70
|
+
release: str | None = None,
|
|
71
|
+
tags: list[str] | None = None,
|
|
72
|
+
metadata: Mapping[str, Any] | None = None,
|
|
73
|
+
version: str | None = None,
|
|
74
|
+
trace_name: str | None = None,
|
|
75
|
+
redact_keys: list[str],
|
|
76
|
+
model: str | None = None,
|
|
77
|
+
response_model: str | None = None,
|
|
78
|
+
provider: str | None = None,
|
|
79
|
+
operation_name: str | None = None,
|
|
80
|
+
temperature: float | None = None,
|
|
81
|
+
max_tokens: int | None = None,
|
|
82
|
+
usage: Mapping[str, int] | None = None,
|
|
83
|
+
cost: Mapping[str, float] | None = None,
|
|
84
|
+
completion_start_time: str | None = None,
|
|
85
|
+
response_id: str | None = None,
|
|
86
|
+
finish_reasons: list[str] | None = None,
|
|
87
|
+
prompt: Mapping[str, Any] | None = None,
|
|
88
|
+
) -> Attributes:
|
|
89
|
+
attrs = build_observation_attributes(
|
|
90
|
+
observation_type="generation",
|
|
91
|
+
attributes=attributes,
|
|
92
|
+
input=input,
|
|
93
|
+
output=output,
|
|
94
|
+
session_id=session_id,
|
|
95
|
+
user_id=user_id,
|
|
96
|
+
release=release,
|
|
97
|
+
tags=tags,
|
|
98
|
+
metadata=metadata,
|
|
99
|
+
version=version,
|
|
100
|
+
trace_name=trace_name,
|
|
101
|
+
redact_keys=redact_keys,
|
|
102
|
+
)
|
|
103
|
+
if operation_name:
|
|
104
|
+
attrs["gen_ai.operation.name"] = operation_name
|
|
105
|
+
if provider:
|
|
106
|
+
attrs["gen_ai.provider.name"] = provider
|
|
107
|
+
if model:
|
|
108
|
+
attrs["gen_ai.request.model"] = model
|
|
109
|
+
if response_model:
|
|
110
|
+
attrs["gen_ai.response.model"] = response_model
|
|
111
|
+
if temperature is not None:
|
|
112
|
+
attrs["gen_ai.request.temperature"] = temperature
|
|
113
|
+
if max_tokens is not None:
|
|
114
|
+
attrs["gen_ai.request.max_tokens"] = max_tokens
|
|
115
|
+
if usage:
|
|
116
|
+
if "input_tokens" in usage:
|
|
117
|
+
attrs["gen_ai.usage.input_tokens"] = usage["input_tokens"]
|
|
118
|
+
if "output_tokens" in usage:
|
|
119
|
+
attrs["gen_ai.usage.output_tokens"] = usage["output_tokens"]
|
|
120
|
+
if "total_tokens" in usage:
|
|
121
|
+
attrs["gen_ai.usage.total_tokens"] = usage["total_tokens"]
|
|
122
|
+
elif "input_tokens" in usage or "output_tokens" in usage:
|
|
123
|
+
attrs["gen_ai.usage.total_tokens"] = int(usage.get("input_tokens", 0)) + int(
|
|
124
|
+
usage.get("output_tokens", 0)
|
|
125
|
+
)
|
|
126
|
+
if cost:
|
|
127
|
+
if "input" in cost:
|
|
128
|
+
attrs["sp.cost.input"] = cost["input"]
|
|
129
|
+
if "output" in cost:
|
|
130
|
+
attrs["sp.cost.output"] = cost["output"]
|
|
131
|
+
if "total" in cost:
|
|
132
|
+
attrs["sp.cost.total"] = cost["total"]
|
|
133
|
+
if completion_start_time:
|
|
134
|
+
attrs["sp.generation.completion_start_time"] = completion_start_time
|
|
135
|
+
if response_id:
|
|
136
|
+
attrs["gen_ai.response.id"] = response_id
|
|
137
|
+
if finish_reasons is not None:
|
|
138
|
+
attrs["gen_ai.response.finish_reasons"] = list(finish_reasons)
|
|
139
|
+
if prompt:
|
|
140
|
+
if prompt.get("id"):
|
|
141
|
+
attrs["sp.prompt.id"] = str(prompt["id"])
|
|
142
|
+
if prompt.get("name"):
|
|
143
|
+
attrs["sp.prompt.name"] = str(prompt["name"])
|
|
144
|
+
if prompt.get("version") is not None:
|
|
145
|
+
attrs["sp.prompt.version"] = int(prompt["version"])
|
|
146
|
+
return attrs
|
softprobe/client.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, AsyncIterator, Iterator, Mapping
|
|
6
|
+
|
|
7
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
8
|
+
from opentelemetry.sdk.resources import Resource
|
|
9
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
10
|
+
from opentelemetry.sdk.trace.export import (
|
|
11
|
+
BatchSpanProcessor,
|
|
12
|
+
SimpleSpanProcessor,
|
|
13
|
+
SpanExporter,
|
|
14
|
+
)
|
|
15
|
+
from opentelemetry.semconv.resource import ResourceAttributes
|
|
16
|
+
|
|
17
|
+
from softprobe.config import (
|
|
18
|
+
MissingSoftprobeCredentialsError,
|
|
19
|
+
derive_otlp_endpoint,
|
|
20
|
+
resolve_softprobe_config_from_env,
|
|
21
|
+
)
|
|
22
|
+
from softprobe.observation import (
|
|
23
|
+
Generation,
|
|
24
|
+
Observation,
|
|
25
|
+
ObservationRuntime,
|
|
26
|
+
observation_scope,
|
|
27
|
+
start_generation,
|
|
28
|
+
start_observation,
|
|
29
|
+
)
|
|
30
|
+
from softprobe.propagation import propagate_attributes
|
|
31
|
+
from softprobe.redaction import default_redact_keys
|
|
32
|
+
from softprobe.scores import HttpScoreTransport, build_score_request
|
|
33
|
+
from softprobe.tools import tool_span_attributes
|
|
34
|
+
from softprobe.types import ObservationType, ScoreDataType, ScoreSource, ScoreTransport
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SoftprobeClient:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
public_key: str,
|
|
42
|
+
base_url: str,
|
|
43
|
+
otlp_endpoint: str | None = None,
|
|
44
|
+
service_name: str = "softprobe-app",
|
|
45
|
+
service_version: str | None = None,
|
|
46
|
+
environment: str | None = None,
|
|
47
|
+
release: str | None = None,
|
|
48
|
+
session_id: str | None = None,
|
|
49
|
+
user_id: str | None = None,
|
|
50
|
+
tags: list[str] | None = None,
|
|
51
|
+
redact_keys: list[str] | None = None,
|
|
52
|
+
timeout_ms: int = 10_000,
|
|
53
|
+
headers: Mapping[str, str] | None = None,
|
|
54
|
+
span_exporter: SpanExporter | None = None,
|
|
55
|
+
score_transport: ScoreTransport | None = None,
|
|
56
|
+
use_simple_processor: bool = False,
|
|
57
|
+
) -> None:
|
|
58
|
+
if not public_key or not public_key.strip():
|
|
59
|
+
raise ValueError("public_key is required")
|
|
60
|
+
if not base_url or not base_url.strip():
|
|
61
|
+
raise ValueError("base_url is required")
|
|
62
|
+
base_url = base_url.strip()
|
|
63
|
+
resolved_otlp = (
|
|
64
|
+
otlp_endpoint.strip()
|
|
65
|
+
if otlp_endpoint and otlp_endpoint.strip()
|
|
66
|
+
else derive_otlp_endpoint(base_url)
|
|
67
|
+
)
|
|
68
|
+
if span_exporter is None and not resolved_otlp:
|
|
69
|
+
raise ValueError("otlp_endpoint is required")
|
|
70
|
+
|
|
71
|
+
resource_attrs: dict[str, str] = {
|
|
72
|
+
ResourceAttributes.SERVICE_NAME: service_name,
|
|
73
|
+
"telemetry.sdk.name": "softprobe",
|
|
74
|
+
}
|
|
75
|
+
if service_version:
|
|
76
|
+
resource_attrs[ResourceAttributes.SERVICE_VERSION] = service_version
|
|
77
|
+
if environment:
|
|
78
|
+
resource_attrs["deployment.environment.name"] = environment
|
|
79
|
+
|
|
80
|
+
exporter = span_exporter or OTLPSpanExporter(
|
|
81
|
+
endpoint=resolved_otlp,
|
|
82
|
+
headers={
|
|
83
|
+
"Authorization": f"Bearer {public_key}",
|
|
84
|
+
**dict(headers or {}),
|
|
85
|
+
},
|
|
86
|
+
timeout=timeout_ms / 1000.0,
|
|
87
|
+
)
|
|
88
|
+
processor = (
|
|
89
|
+
SimpleSpanProcessor(exporter)
|
|
90
|
+
if use_simple_processor
|
|
91
|
+
else BatchSpanProcessor(exporter)
|
|
92
|
+
)
|
|
93
|
+
self._provider = TracerProvider(resource=Resource.create(resource_attrs))
|
|
94
|
+
self._provider.add_span_processor(processor)
|
|
95
|
+
# Avoid set_tracer_provider so multiple clients/tests can coexist.
|
|
96
|
+
# Parent/child linking uses explicit context attach on observations.
|
|
97
|
+
|
|
98
|
+
self._runtime = ObservationRuntime(
|
|
99
|
+
tracer=self._provider.get_tracer("softprobe", "0.1.0"),
|
|
100
|
+
redact_keys=redact_keys or default_redact_keys(),
|
|
101
|
+
session_id=session_id,
|
|
102
|
+
user_id=user_id,
|
|
103
|
+
release=release,
|
|
104
|
+
tags=tags,
|
|
105
|
+
)
|
|
106
|
+
self._score_transport = score_transport or HttpScoreTransport(
|
|
107
|
+
base_url,
|
|
108
|
+
public_key.strip(),
|
|
109
|
+
headers or {},
|
|
110
|
+
timeout_ms,
|
|
111
|
+
)
|
|
112
|
+
self._shut_down = False
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_env(cls, **overrides: Any) -> SoftprobeClient:
|
|
116
|
+
"""Build a client from ``SOFTPROBE_*`` environment variables.
|
|
117
|
+
|
|
118
|
+
Requires ``SOFTPROBE_PUBLIC_KEY`` and ``SOFTPROBE_BASE_URL``.
|
|
119
|
+
Keyword overrides are merged on top of the resolved env config.
|
|
120
|
+
"""
|
|
121
|
+
cfg = resolve_softprobe_config_from_env()
|
|
122
|
+
if cfg is None:
|
|
123
|
+
raise MissingSoftprobeCredentialsError(
|
|
124
|
+
"Set SOFTPROBE_PUBLIC_KEY and SOFTPROBE_BASE_URL"
|
|
125
|
+
)
|
|
126
|
+
kwargs: dict[str, Any] = {
|
|
127
|
+
"public_key": cfg["public_key"],
|
|
128
|
+
"base_url": cfg["base_url"],
|
|
129
|
+
"otlp_endpoint": cfg.get("otlp_endpoint"),
|
|
130
|
+
}
|
|
131
|
+
if cfg.get("environment"):
|
|
132
|
+
kwargs["environment"] = cfg["environment"]
|
|
133
|
+
if cfg.get("session_id"):
|
|
134
|
+
kwargs["session_id"] = cfg["session_id"]
|
|
135
|
+
if cfg.get("user_id"):
|
|
136
|
+
kwargs["user_id"] = cfg["user_id"]
|
|
137
|
+
if cfg.get("service_name"):
|
|
138
|
+
kwargs["service_name"] = cfg["service_name"]
|
|
139
|
+
kwargs.update(overrides)
|
|
140
|
+
return cls(**kwargs)
|
|
141
|
+
|
|
142
|
+
def start_observation(
|
|
143
|
+
self,
|
|
144
|
+
*,
|
|
145
|
+
name: str,
|
|
146
|
+
as_type: ObservationType = "span",
|
|
147
|
+
attributes: Mapping[str, Any] | None = None,
|
|
148
|
+
input: Any | None = None,
|
|
149
|
+
output: Any | None = None,
|
|
150
|
+
session_id: str | None = None,
|
|
151
|
+
user_id: str | None = None,
|
|
152
|
+
release: str | None = None,
|
|
153
|
+
tags: list[str] | None = None,
|
|
154
|
+
metadata: Mapping[str, Any] | None = None,
|
|
155
|
+
version: str | None = None,
|
|
156
|
+
trace_name: str | None = None,
|
|
157
|
+
parent: Observation | None = None,
|
|
158
|
+
trace_context: Mapping[str, str] | None = None,
|
|
159
|
+
start_time: datetime | float | int | None = None,
|
|
160
|
+
) -> Observation:
|
|
161
|
+
self._assert_active()
|
|
162
|
+
return start_observation(
|
|
163
|
+
self._runtime,
|
|
164
|
+
name=name,
|
|
165
|
+
as_type=as_type,
|
|
166
|
+
attributes=attributes,
|
|
167
|
+
input=input,
|
|
168
|
+
output=output,
|
|
169
|
+
session_id=session_id,
|
|
170
|
+
user_id=user_id,
|
|
171
|
+
release=release,
|
|
172
|
+
tags=tags,
|
|
173
|
+
metadata=metadata,
|
|
174
|
+
version=version,
|
|
175
|
+
trace_name=trace_name,
|
|
176
|
+
parent=parent,
|
|
177
|
+
trace_context=trace_context,
|
|
178
|
+
start_time=start_time,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def start_generation(self, *, name: str, **kwargs: Any) -> Generation:
|
|
182
|
+
self._assert_active()
|
|
183
|
+
return start_generation(self._runtime, name=name, **kwargs)
|
|
184
|
+
|
|
185
|
+
def propagate_attributes(self, **kwargs: Any) -> Any:
|
|
186
|
+
"""Context manager that scopes session/user/tags/metadata for nested work."""
|
|
187
|
+
return propagate_attributes(**kwargs)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def tracer(self) -> Any:
|
|
191
|
+
return self._runtime.tracer
|
|
192
|
+
|
|
193
|
+
def start_agent(self, *, name: str, **kwargs: Any) -> Observation:
|
|
194
|
+
return self.start_observation(name=name, as_type="agent", **kwargs)
|
|
195
|
+
|
|
196
|
+
def start_tool(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
name: str,
|
|
200
|
+
tool_name: str | None = None,
|
|
201
|
+
tool_call_id: str | None = None,
|
|
202
|
+
kind: str | None = None,
|
|
203
|
+
status: str | None = None,
|
|
204
|
+
index: int | None = None,
|
|
205
|
+
mcp_server: str | None = None,
|
|
206
|
+
mcp_tool: str | None = None,
|
|
207
|
+
attributes: Mapping[str, Any] | None = None,
|
|
208
|
+
**kwargs: Any,
|
|
209
|
+
) -> Observation:
|
|
210
|
+
merged = dict(attributes or {})
|
|
211
|
+
merged.update(
|
|
212
|
+
tool_span_attributes(
|
|
213
|
+
tool_name=tool_name or name,
|
|
214
|
+
tool_call_id=tool_call_id,
|
|
215
|
+
kind=kind or "function",
|
|
216
|
+
status=status or "ok",
|
|
217
|
+
index=index,
|
|
218
|
+
mcp_server=mcp_server,
|
|
219
|
+
mcp_tool=mcp_tool,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
return self.start_observation(
|
|
223
|
+
name=name, as_type="tool", attributes=merged, **kwargs
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def start_chain(self, *, name: str, **kwargs: Any) -> Observation:
|
|
227
|
+
return self.start_observation(name=name, as_type="chain", **kwargs)
|
|
228
|
+
|
|
229
|
+
def start_retriever(self, *, name: str, **kwargs: Any) -> Observation:
|
|
230
|
+
return self.start_observation(name=name, as_type="retriever", **kwargs)
|
|
231
|
+
|
|
232
|
+
def start_evaluator(self, *, name: str, **kwargs: Any) -> Observation:
|
|
233
|
+
return self.start_observation(name=name, as_type="evaluator", **kwargs)
|
|
234
|
+
|
|
235
|
+
def start_embedding(self, *, name: str, **kwargs: Any) -> Observation:
|
|
236
|
+
return self.start_observation(name=name, as_type="embedding", **kwargs)
|
|
237
|
+
|
|
238
|
+
def start_guardrail(self, *, name: str, **kwargs: Any) -> Observation:
|
|
239
|
+
return self.start_observation(name=name, as_type="guardrail", **kwargs)
|
|
240
|
+
|
|
241
|
+
def start_event(self, *, name: str, **kwargs: Any) -> Observation:
|
|
242
|
+
return self.start_observation(name=name, as_type="event", **kwargs)
|
|
243
|
+
|
|
244
|
+
@contextmanager
|
|
245
|
+
def observation(self, *, name: str, **kwargs: Any) -> Iterator[Observation]:
|
|
246
|
+
obs = self.start_observation(name=name, **kwargs)
|
|
247
|
+
with observation_scope(obs) as scoped:
|
|
248
|
+
yield scoped
|
|
249
|
+
|
|
250
|
+
@contextmanager
|
|
251
|
+
def generation(self, *, name: str, **kwargs: Any) -> Iterator[Generation]:
|
|
252
|
+
gen = self.start_generation(name=name, **kwargs)
|
|
253
|
+
with observation_scope(gen) as scoped:
|
|
254
|
+
yield scoped # type: ignore[misc]
|
|
255
|
+
|
|
256
|
+
@asynccontextmanager
|
|
257
|
+
async def async_observation(
|
|
258
|
+
self, *, name: str, **kwargs: Any
|
|
259
|
+
) -> AsyncIterator[Observation]:
|
|
260
|
+
with self.observation(name=name, **kwargs) as obs:
|
|
261
|
+
yield obs
|
|
262
|
+
|
|
263
|
+
@asynccontextmanager
|
|
264
|
+
async def async_generation(
|
|
265
|
+
self, *, name: str, **kwargs: Any
|
|
266
|
+
) -> AsyncIterator[Generation]:
|
|
267
|
+
with self.generation(name=name, **kwargs) as gen:
|
|
268
|
+
yield gen
|
|
269
|
+
|
|
270
|
+
def create_score(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
score_id: str,
|
|
274
|
+
name: str,
|
|
275
|
+
data_type: ScoreDataType,
|
|
276
|
+
source: ScoreSource,
|
|
277
|
+
timestamp: str | datetime | None = None,
|
|
278
|
+
trace_id: str | None = None,
|
|
279
|
+
span_id: str | None = None,
|
|
280
|
+
session_id: str | None = None,
|
|
281
|
+
numeric_value: float | None = None,
|
|
282
|
+
string_value: str | None = None,
|
|
283
|
+
boolean_value: bool | None = None,
|
|
284
|
+
comment: str | None = None,
|
|
285
|
+
config_id: str | None = None,
|
|
286
|
+
author_id: str | None = None,
|
|
287
|
+
metadata: Mapping[str, str] | None = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
self._assert_active()
|
|
290
|
+
request = build_score_request(
|
|
291
|
+
score_id=score_id,
|
|
292
|
+
name=name,
|
|
293
|
+
data_type=data_type,
|
|
294
|
+
source=source,
|
|
295
|
+
timestamp=timestamp,
|
|
296
|
+
trace_id=trace_id,
|
|
297
|
+
span_id=span_id,
|
|
298
|
+
session_id=session_id,
|
|
299
|
+
numeric_value=numeric_value,
|
|
300
|
+
string_value=string_value,
|
|
301
|
+
boolean_value=boolean_value,
|
|
302
|
+
comment=comment,
|
|
303
|
+
config_id=config_id,
|
|
304
|
+
author_id=author_id,
|
|
305
|
+
metadata=metadata,
|
|
306
|
+
)
|
|
307
|
+
self._score_transport.create_score(request)
|
|
308
|
+
|
|
309
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
310
|
+
return bool(self._provider.force_flush(timeout_millis))
|
|
311
|
+
|
|
312
|
+
def flush(self, timeout_millis: int = 30_000) -> bool:
|
|
313
|
+
"""Alias for :meth:`force_flush`."""
|
|
314
|
+
return self.force_flush(timeout_millis)
|
|
315
|
+
|
|
316
|
+
def shutdown(self) -> None:
|
|
317
|
+
if self._shut_down:
|
|
318
|
+
return
|
|
319
|
+
self._shut_down = True
|
|
320
|
+
self._provider.shutdown()
|
|
321
|
+
|
|
322
|
+
def _assert_active(self) -> None:
|
|
323
|
+
if self._shut_down:
|
|
324
|
+
raise RuntimeError("SoftprobeClient has been shut down")
|
softprobe/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Mapping, TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ResolvedSoftprobeConfig(TypedDict, total=False):
|
|
8
|
+
public_key: str
|
|
9
|
+
base_url: str
|
|
10
|
+
otlp_endpoint: str
|
|
11
|
+
environment: str
|
|
12
|
+
session_id: str
|
|
13
|
+
user_id: str
|
|
14
|
+
service_name: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MissingSoftprobeCredentialsError(ValueError):
|
|
18
|
+
"""Raised when Softprobe credentials cannot be resolved."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def as_non_empty_string(value: Any) -> str | None:
|
|
22
|
+
if not isinstance(value, str):
|
|
23
|
+
return None
|
|
24
|
+
trimmed = value.strip()
|
|
25
|
+
return trimmed if trimmed else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def derive_otlp_endpoint(base_url: str, explicit: str | None = None) -> str:
|
|
29
|
+
if explicit and explicit.strip():
|
|
30
|
+
return explicit.strip()
|
|
31
|
+
return f"{base_url.rstrip('/')}/v1/traces"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_softprobe_config_from_env(
|
|
35
|
+
env: Mapping[str, str | None] | None = None,
|
|
36
|
+
) -> ResolvedSoftprobeConfig | None:
|
|
37
|
+
source = env if env is not None else os.environ
|
|
38
|
+
public_key = as_non_empty_string(source.get("SOFTPROBE_PUBLIC_KEY"))
|
|
39
|
+
base_url = as_non_empty_string(source.get("SOFTPROBE_BASE_URL"))
|
|
40
|
+
if not public_key or not base_url:
|
|
41
|
+
return None
|
|
42
|
+
return ResolvedSoftprobeConfig(
|
|
43
|
+
public_key=public_key,
|
|
44
|
+
base_url=base_url,
|
|
45
|
+
otlp_endpoint=derive_otlp_endpoint(
|
|
46
|
+
base_url,
|
|
47
|
+
as_non_empty_string(source.get("SOFTPROBE_OTLP_ENDPOINT")),
|
|
48
|
+
),
|
|
49
|
+
environment=as_non_empty_string(source.get("SOFTPROBE_ENVIRONMENT")),
|
|
50
|
+
session_id=as_non_empty_string(source.get("SOFTPROBE_SESSION_ID")),
|
|
51
|
+
user_id=as_non_empty_string(source.get("SOFTPROBE_USER_ID")),
|
|
52
|
+
service_name=as_non_empty_string(source.get("SOFTPROBE_SERVICE_NAME")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_softprobe_config_from_mapping(raw: Mapping[str, Any]) -> ResolvedSoftprobeConfig:
|
|
57
|
+
public_key = as_non_empty_string(raw.get("public_key") or raw.get("publicKey"))
|
|
58
|
+
base_url = as_non_empty_string(raw.get("base_url") or raw.get("baseUrl"))
|
|
59
|
+
if not public_key or not base_url:
|
|
60
|
+
raise MissingSoftprobeCredentialsError("public_key and base_url are required")
|
|
61
|
+
explicit = as_non_empty_string(raw.get("otlp_endpoint") or raw.get("otlpEndpoint"))
|
|
62
|
+
return ResolvedSoftprobeConfig(
|
|
63
|
+
public_key=public_key,
|
|
64
|
+
base_url=base_url,
|
|
65
|
+
otlp_endpoint=derive_otlp_endpoint(base_url, explicit),
|
|
66
|
+
environment=as_non_empty_string(raw.get("environment")),
|
|
67
|
+
session_id=as_non_empty_string(raw.get("session_id") or raw.get("sessionId")),
|
|
68
|
+
user_id=as_non_empty_string(raw.get("user_id") or raw.get("userId")),
|
|
69
|
+
service_name=as_non_empty_string(raw.get("service_name") or raw.get("serviceName")),
|
|
70
|
+
)
|
softprobe/identity.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Resolve product conversation / user identity from the builder's world.
|
|
2
|
+
|
|
3
|
+
Priority: LangChain/LangGraph run metadata (incl. configurable) →
|
|
4
|
+
explicit Softprobe fallbacks → SOFTPROBE_* env.
|
|
5
|
+
Softprobe never invents a session id.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Mapping
|
|
12
|
+
|
|
13
|
+
SESSION_KEYS = (
|
|
14
|
+
"thread_id",
|
|
15
|
+
"threadId",
|
|
16
|
+
"session_id",
|
|
17
|
+
"sessionId",
|
|
18
|
+
"conversation_id",
|
|
19
|
+
"conversationId",
|
|
20
|
+
"chat_id",
|
|
21
|
+
"chatId",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
USER_KEYS = ("user_id", "userId", "user")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _as_non_empty(value: Any) -> str | None:
|
|
28
|
+
if not isinstance(value, str):
|
|
29
|
+
return None
|
|
30
|
+
trimmed = value.strip()
|
|
31
|
+
return trimmed or None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _first_key(sources: list[Mapping[str, Any] | None], keys: tuple[str, ...]) -> str | None:
|
|
35
|
+
for source in sources:
|
|
36
|
+
if not source:
|
|
37
|
+
continue
|
|
38
|
+
for key in keys:
|
|
39
|
+
found = _as_non_empty(source.get(key))
|
|
40
|
+
if found:
|
|
41
|
+
return found
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def identity_sources_from_metadata(
|
|
46
|
+
metadata: Mapping[str, Any] | None,
|
|
47
|
+
) -> list[Mapping[str, Any] | None]:
|
|
48
|
+
if not metadata:
|
|
49
|
+
return []
|
|
50
|
+
configurable = metadata.get("configurable")
|
|
51
|
+
if isinstance(configurable, Mapping):
|
|
52
|
+
return [configurable, metadata]
|
|
53
|
+
return [metadata]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_run_identity(
|
|
57
|
+
*,
|
|
58
|
+
metadata: Mapping[str, Any] | None = None,
|
|
59
|
+
fallback_session_id: str | None = None,
|
|
60
|
+
fallback_user_id: str | None = None,
|
|
61
|
+
env: Mapping[str, str | None] | None = None,
|
|
62
|
+
) -> tuple[str | None, str | None]:
|
|
63
|
+
source_env = env if env is not None else os.environ
|
|
64
|
+
from_meta = identity_sources_from_metadata(metadata)
|
|
65
|
+
session_id = (
|
|
66
|
+
_first_key(from_meta, SESSION_KEYS)
|
|
67
|
+
or _as_non_empty(fallback_session_id)
|
|
68
|
+
or _as_non_empty(source_env.get("SOFTPROBE_SESSION_ID"))
|
|
69
|
+
)
|
|
70
|
+
user_id = (
|
|
71
|
+
_first_key(from_meta, USER_KEYS)
|
|
72
|
+
or _as_non_empty(fallback_user_id)
|
|
73
|
+
or _as_non_empty(source_env.get("SOFTPROBE_USER_ID"))
|
|
74
|
+
)
|
|
75
|
+
return session_id, user_id
|