computer-agent-py 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.
@@ -0,0 +1,375 @@
1
+ """``OtelSink`` — emit GenAI-semconv-compliant OpenTelemetry spans + metrics.
2
+
3
+ Span tree matches the TS ``@computeragent/observability`` package so polyglot
4
+ deployments produce one coherent trace tree in the same backend::
5
+
6
+ invoke_agent <agent_name> [gen_ai.conversation.id = session_id,
7
+ gen_ai.system = "claude-agent-sdk",
8
+ gen_ai.agent.name = ...]
9
+ └── chat <model> [gen_ai.request.model = ...]
10
+ └── execute_tool <name> [gen_ai.tool.name = ...,
11
+ gen_ai.tool.call.id = ...]
12
+
13
+ Metrics::
14
+
15
+ gen_ai.client.token.usage (histogram, unit "{token}")
16
+ dims: gen_ai.operation.name, gen_ai.request.model, gen_ai.token.type
17
+ gen_ai.client.operation.duration (histogram, unit "s")
18
+ computeragent.usage.cost_usd (histogram, unit "{usd}") — custom
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import logging
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ try:
28
+ from opentelemetry import metrics, trace # noqa: F401 - probe imports only
29
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
30
+ OTLPMetricExporter as HttpMetricExporter,
31
+ )
32
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
33
+ OTLPSpanExporter as HttpSpanExporter,
34
+ )
35
+ from opentelemetry.sdk.metrics import MeterProvider
36
+ from opentelemetry.sdk.metrics.export import (
37
+ ConsoleMetricExporter,
38
+ PeriodicExportingMetricReader,
39
+ )
40
+ from opentelemetry.sdk.resources import Resource
41
+ from opentelemetry.sdk.trace import TracerProvider
42
+ from opentelemetry.sdk.trace.export import (
43
+ BatchSpanProcessor,
44
+ ConsoleSpanExporter,
45
+ SimpleSpanProcessor,
46
+ )
47
+ except ImportError as _exc: # pragma: no cover - guarded by lazy export
48
+ raise ImportError(
49
+ "OtelSink requires the [otel] extra. Install with: pip install 'computer-agent-py[otel]'"
50
+ ) from _exc
51
+
52
+ if TYPE_CHECKING:
53
+ from opentelemetry.trace import Span
54
+
55
+ from ..event import TelemetryEvent
56
+
57
+ logger = logging.getLogger("computeragent.telemetry")
58
+
59
+
60
+ # GenAI semconv attribute keys — pinned literals to guarantee parity with the
61
+ # TS @computeragent/observability emission. The semconv Python package's
62
+ # constants for these keys move between alpha versions; literals are safer.
63
+ GEN_AI_SYSTEM = "gen_ai.system"
64
+ GEN_AI_AGENT_NAME = "gen_ai.agent.name"
65
+ GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"
66
+ GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
67
+ GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
68
+ GEN_AI_TOOL_NAME = "gen_ai.tool.name"
69
+ GEN_AI_TOOL_CALL_ID = "gen_ai.tool.call.id"
70
+ GEN_AI_TOKEN_TYPE = "gen_ai.token.type"
71
+ GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
72
+ GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
73
+ GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read_input_tokens"
74
+ GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation_input_tokens"
75
+
76
+ METRIC_TOKEN_USAGE = "gen_ai.client.token.usage"
77
+ METRIC_OPERATION_DURATION = "gen_ai.client.operation.duration"
78
+ METRIC_COST_USD = "computeragent.usage.cost_usd"
79
+
80
+
81
+ class OtelSink:
82
+ """Translate :class:`TelemetryEvent` into OTel spans + metric measurements.
83
+
84
+ Configures its own ``TracerProvider`` and ``MeterProvider`` on first use
85
+ unless ``tracer_provider`` / ``meter_provider`` is passed in (tests use
86
+ this hook to attach in-memory exporters).
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ service_name: str | None = None,
93
+ exporter: str | None = None,
94
+ endpoint: str | None = None,
95
+ headers: dict[str, str] | None = None,
96
+ tracer_provider: Any | None = None,
97
+ meter_provider: Any | None = None,
98
+ ) -> None:
99
+ # Read effective config from the module-level resolver if no overrides.
100
+ from ..config import get_config
101
+
102
+ cfg = get_config()
103
+ self._service_name = service_name or cfg.get("service_name") or "computeragent"
104
+ self._exporter = (
105
+ exporter or cfg.get("exporter") or _infer_exporter(endpoint or cfg.get("endpoint"))
106
+ )
107
+ self._endpoint = endpoint or cfg.get("endpoint")
108
+ self._headers = headers or cfg.get("headers") or {}
109
+
110
+ self._tracer_provider = tracer_provider or _build_tracer_provider(
111
+ service_name=self._service_name,
112
+ exporter=self._exporter,
113
+ endpoint=self._endpoint,
114
+ headers=self._headers,
115
+ )
116
+ self._meter_provider = meter_provider or _build_meter_provider(
117
+ service_name=self._service_name,
118
+ exporter=self._exporter,
119
+ endpoint=self._endpoint,
120
+ headers=self._headers,
121
+ )
122
+ self._tracer = self._tracer_provider.get_tracer("computeragent")
123
+ self._meter = self._meter_provider.get_meter("computeragent")
124
+
125
+ self._token_usage_hist = self._meter.create_histogram(
126
+ METRIC_TOKEN_USAGE, unit="{token}", description="GenAI token usage"
127
+ )
128
+ self._duration_hist = self._meter.create_histogram(
129
+ METRIC_OPERATION_DURATION, unit="s", description="GenAI operation duration"
130
+ )
131
+ self._cost_hist = self._meter.create_histogram(
132
+ METRIC_COST_USD, unit="{usd}", description="Inference cost in USD"
133
+ )
134
+
135
+ # session_id → (invoke_agent span, chat span, active execute_tool span)
136
+ # Spans are held open across events for the same session/turn.
137
+ self._invoke_spans: dict[str, Span] = {}
138
+ self._chat_spans: dict[str, Span] = {}
139
+ self._tool_spans: dict[str, dict[str, Span]] = {} # session → tool_use_id → span
140
+
141
+ # ── lifecycle ----------------------------------------------------------
142
+
143
+ async def emit(self, event: TelemetryEvent) -> None:
144
+ handler = _DISPATCH.get(event.kind)
145
+ if handler is None:
146
+ return
147
+ handler(self, event)
148
+
149
+ async def flush(self, timeout: float = 5.0) -> None:
150
+ # OTel SDK's flush takes milliseconds. Convert + fan out.
151
+ ms = int(timeout * 1000)
152
+ try:
153
+ self._tracer_provider.force_flush(timeout_millis=ms)
154
+ except Exception: # noqa: BLE001
155
+ logger.debug("tracer_provider.force_flush failed", exc_info=True)
156
+ try:
157
+ self._meter_provider.force_flush(timeout_millis=ms)
158
+ except Exception: # noqa: BLE001
159
+ logger.debug("meter_provider.force_flush failed", exc_info=True)
160
+
161
+ async def aclose(self) -> None:
162
+ await self.flush()
163
+ try:
164
+ self._tracer_provider.shutdown()
165
+ except Exception: # noqa: BLE001
166
+ logger.debug("tracer_provider.shutdown failed", exc_info=True)
167
+ try:
168
+ self._meter_provider.shutdown()
169
+ except Exception: # noqa: BLE001
170
+ logger.debug("meter_provider.shutdown failed", exc_info=True)
171
+
172
+ # ── handlers (one per event kind) --------------------------------------
173
+
174
+ def _on_session_started(self, event: TelemetryEvent) -> None:
175
+ span = self._tracer.start_span(name=f"invoke_agent {event.agent_name or 'agent'}")
176
+ span.set_attribute(GEN_AI_SYSTEM, "claude-agent-sdk")
177
+ if event.agent_name:
178
+ span.set_attribute(GEN_AI_AGENT_NAME, event.agent_name)
179
+ span.set_attribute(GEN_AI_CONVERSATION_ID, event.session_id)
180
+ span.set_attribute(GEN_AI_OPERATION_NAME, "invoke_agent")
181
+ if event.payload.get("model"):
182
+ span.set_attribute(GEN_AI_REQUEST_MODEL, str(event.payload["model"]))
183
+ self._invoke_spans[event.session_id] = span
184
+
185
+ def _on_assistant_message(self, event: TelemetryEvent) -> None:
186
+ # Open or reuse a chat span; spec models chat as one span per LLM call,
187
+ # but the upstream stream gives us one AssistantMessage per LLM call,
188
+ # so open/close per event.
189
+ chat = self._tracer.start_span(name=f"chat {event.payload.get('model') or 'unknown'}")
190
+ chat.set_attribute(GEN_AI_OPERATION_NAME, "chat")
191
+ if event.payload.get("model"):
192
+ chat.set_attribute(GEN_AI_REQUEST_MODEL, str(event.payload["model"]))
193
+ chat.set_attribute(GEN_AI_CONVERSATION_ID, event.session_id)
194
+ chat.end()
195
+
196
+ def _on_tool_use(self, event: TelemetryEvent) -> None:
197
+ tool_name = str(event.payload.get("tool_name") or "tool")
198
+ span = self._tracer.start_span(name=f"execute_tool {tool_name}")
199
+ span.set_attribute(GEN_AI_OPERATION_NAME, "execute_tool")
200
+ span.set_attribute(GEN_AI_TOOL_NAME, tool_name)
201
+ call_id = str(event.payload.get("tool_use_id") or "")
202
+ if call_id:
203
+ span.set_attribute(GEN_AI_TOOL_CALL_ID, call_id)
204
+ span.set_attribute(GEN_AI_CONVERSATION_ID, event.session_id)
205
+ self._tool_spans.setdefault(event.session_id, {})[call_id] = span
206
+
207
+ def _on_tool_result(self, event: TelemetryEvent) -> None:
208
+ call_id = str(event.payload.get("tool_use_id") or "")
209
+ span = self._tool_spans.get(event.session_id, {}).pop(call_id, None)
210
+ if span is None:
211
+ return
212
+ if event.payload.get("is_error"):
213
+ span.set_attribute("error.type", "tool_error")
214
+ span.end()
215
+
216
+ def _on_usage_snapshot(self, event: TelemetryEvent) -> None:
217
+ usage = event.payload.get("usage") or {}
218
+ model = event.payload.get("model")
219
+ common_dims = {
220
+ GEN_AI_OPERATION_NAME: "chat",
221
+ **({GEN_AI_REQUEST_MODEL: str(model)} if model else {}),
222
+ }
223
+ for token_type, key in (
224
+ ("input", "input_tokens"),
225
+ ("output", "output_tokens"),
226
+ ("cache_read", "cache_read_input_tokens"),
227
+ ("cache_creation", "cache_creation_input_tokens"),
228
+ ):
229
+ raw = usage.get(key)
230
+ if raw is None:
231
+ continue
232
+ try:
233
+ v = int(raw)
234
+ except (TypeError, ValueError):
235
+ continue
236
+ self._token_usage_hist.record(
237
+ v, attributes={**common_dims, GEN_AI_TOKEN_TYPE: token_type}
238
+ )
239
+
240
+ cost = event.payload.get("total_cost_usd")
241
+ if cost is not None:
242
+ with contextlib.suppress(TypeError, ValueError):
243
+ self._cost_hist.record(float(cost), attributes=common_dims)
244
+
245
+ def _on_session_ended(self, event: TelemetryEvent) -> None:
246
+ # Close any tool spans still open for this session (defensive — should
247
+ # already be closed by tool_result events).
248
+ for span in self._tool_spans.pop(event.session_id, {}).values():
249
+ try:
250
+ span.end()
251
+ except Exception: # noqa: BLE001
252
+ logger.debug("stray tool span end failed", exc_info=True)
253
+
254
+ invoke = self._invoke_spans.pop(event.session_id, None)
255
+ if invoke is None:
256
+ return
257
+ if event.payload.get("is_error"):
258
+ invoke.set_attribute(
259
+ "error.type",
260
+ str(event.payload.get("error_type") or event.payload.get("subtype") or "error"),
261
+ )
262
+ usage = event.payload.get("usage") or {}
263
+ for key, attr in (
264
+ ("input_tokens", GEN_AI_USAGE_INPUT_TOKENS),
265
+ ("output_tokens", GEN_AI_USAGE_OUTPUT_TOKENS),
266
+ ("cache_read_input_tokens", GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS),
267
+ ("cache_creation_input_tokens", GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS),
268
+ ):
269
+ v = usage.get(key)
270
+ if isinstance(v, int):
271
+ invoke.set_attribute(attr, v)
272
+ invoke.end()
273
+ duration_s = float(event.payload.get("duration_ms", 0.0) or 0.0) / 1000.0
274
+ if duration_s > 0:
275
+ self._duration_hist.record(
276
+ duration_s, attributes={GEN_AI_OPERATION_NAME: "invoke_agent"}
277
+ )
278
+
279
+ def _on_user_message(self, _event: TelemetryEvent) -> None:
280
+ # No span; user messages are inputs, recorded as conversation events
281
+ # on the active invoke_agent span in a future iteration.
282
+ return
283
+
284
+ def _on_system_message(self, _event: TelemetryEvent) -> None:
285
+ return
286
+
287
+ def _on_policy_decision(self, event: TelemetryEvent) -> None:
288
+ # Annotate the active execute_tool span for this session, if any.
289
+ # Otherwise drop a span event onto the invoke_agent span so the
290
+ # decision is still visible in the trace.
291
+ attrs: dict[str, Any] = {
292
+ "policy.engine": event.payload.get("engine", ""),
293
+ "policy.decision": event.payload.get("decision", ""),
294
+ "policy.reason": event.payload.get("reason", ""),
295
+ "policy.latency_ms": float(event.payload.get("latency_ms", 0.0) or 0.0),
296
+ "policy.tool_name": event.payload.get("tool_name", ""),
297
+ }
298
+ tool_name = str(event.payload.get("tool_name") or "")
299
+ tool_spans = self._tool_spans.get(event.session_id, {})
300
+ # Tool spans are keyed by tool_use_id, which we don't have here. Pick
301
+ # the most-recently-opened span with a matching tool name.
302
+ target_span = None
303
+ for span in tool_spans.values():
304
+ if getattr(span, "name", "").endswith(tool_name) and tool_name:
305
+ target_span = span
306
+ break
307
+ if target_span is None:
308
+ target_span = self._invoke_spans.get(event.session_id)
309
+ if target_span is not None:
310
+ try:
311
+ target_span.add_event("policy_decision", attributes=attrs)
312
+ except Exception: # noqa: BLE001
313
+ logger.debug("policy_decision span event failed", exc_info=True)
314
+ return
315
+ # No active span — emit a standalone span so the decision is captured.
316
+ try:
317
+ span = self._tracer.start_span(name=f"policy_decision {tool_name or 'tool'}")
318
+ for k, v in attrs.items():
319
+ span.set_attribute(k, v)
320
+ span.set_attribute(GEN_AI_CONVERSATION_ID, event.session_id)
321
+ span.end()
322
+ except Exception: # noqa: BLE001
323
+ logger.debug("policy_decision standalone span failed", exc_info=True)
324
+
325
+
326
+ # Map kind → instance method. Resolved at module import; no per-event isinstance.
327
+ _DISPATCH: dict[str, Any] = {
328
+ "session_started": OtelSink._on_session_started,
329
+ "assistant_message": OtelSink._on_assistant_message,
330
+ "tool_use": OtelSink._on_tool_use,
331
+ "tool_result": OtelSink._on_tool_result,
332
+ "usage_snapshot": OtelSink._on_usage_snapshot,
333
+ "session_ended": OtelSink._on_session_ended,
334
+ "user_message": OtelSink._on_user_message,
335
+ "system_message": OtelSink._on_system_message,
336
+ "policy_decision": OtelSink._on_policy_decision,
337
+ }
338
+
339
+
340
+ def _infer_exporter(endpoint: str | None) -> str:
341
+ return "otlp-http" if endpoint else "console"
342
+
343
+
344
+ def _build_tracer_provider(
345
+ *,
346
+ service_name: str,
347
+ exporter: str,
348
+ endpoint: str | None,
349
+ headers: dict[str, str],
350
+ ) -> TracerProvider:
351
+ resource = Resource.create({"service.name": service_name})
352
+ provider = TracerProvider(resource=resource)
353
+ if exporter == "otlp-http":
354
+ span_exporter = HttpSpanExporter(endpoint=endpoint, headers=headers or None)
355
+ provider.add_span_processor(BatchSpanProcessor(span_exporter))
356
+ else:
357
+ provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
358
+ return provider
359
+
360
+
361
+ def _build_meter_provider(
362
+ *,
363
+ service_name: str,
364
+ exporter: str,
365
+ endpoint: str | None,
366
+ headers: dict[str, str],
367
+ ) -> MeterProvider:
368
+ resource = Resource.create({"service.name": service_name})
369
+ metric_exporter: Any
370
+ if exporter == "otlp-http":
371
+ metric_exporter = HttpMetricExporter(endpoint=endpoint, headers=headers or None)
372
+ else:
373
+ metric_exporter = ConsoleMetricExporter()
374
+ reader = PeriodicExportingMetricReader(metric_exporter, export_interval_millis=5000)
375
+ return MeterProvider(resource=resource, metric_readers=[reader])
computeragent/types.py ADDED
@@ -0,0 +1,13 @@
1
+ """Re-export of ``claude_agent_sdk.types``.
2
+
3
+ Every type that ``claude_agent_sdk.types`` exposes is re-exported here with
4
+ identity preserved. This is what makes ``isinstance(msg, ResultMessage)`` work
5
+ whether the user imported ``ResultMessage`` from ``claude_agent_sdk.types`` or
6
+ from ``computeragent.types``.
7
+
8
+ Switching the import line is the only change a drop-in user needs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from claude_agent_sdk.types import * # noqa: F401, F403