ioa-observe-sdk 1.0.26__py3-none-any.whl → 1.0.27__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.
@@ -204,6 +204,122 @@ class A2AInstrumentor(BaseInstrumentor):
204
204
 
205
205
  # original_server_on_message_send = DefaultRequestHandler.on_message_send
206
206
 
207
+ # Instrumentation for slima2a
208
+ if importlib.util.find_spec("slima2a"):
209
+ from slima2a.client_transport import SRPCTransport
210
+
211
+ # send_message
212
+ original_srpc_transport_send_message = SRPCTransport.send_message
213
+
214
+ @functools.wraps(original_srpc_transport_send_message)
215
+ async def instrumented_srpc_transport_send_message(
216
+ self, request, *args, **kwargs
217
+ ):
218
+ # Put context into A2A message metadata instead of HTTP headers
219
+ with _global_tracer.start_as_current_span("slima2a.send_message"):
220
+ traceparent = get_current_traceparent()
221
+ session_id = None
222
+ if traceparent:
223
+ session_id = kv_store.get(f"execution.{traceparent}")
224
+ if not session_id:
225
+ session_id = get_value("session.id")
226
+ if session_id:
227
+ kv_store.set(f"execution.{traceparent}", session_id)
228
+
229
+ # Ensure metadata dict exists
230
+ try:
231
+ md = getattr(request.params, "metadata", None)
232
+ except AttributeError:
233
+ md = None
234
+ metadata = md if isinstance(md, dict) else {}
235
+
236
+ observe_meta = dict(metadata.get("observe", {}))
237
+
238
+ # Inject W3C trace context + baggage into observe_meta
239
+ TraceContextTextMapPropagator().inject(carrier=observe_meta)
240
+ W3CBaggagePropagator().inject(carrier=observe_meta)
241
+
242
+ if traceparent:
243
+ observe_meta["traceparent"] = traceparent
244
+ if session_id:
245
+ observe_meta["session_id"] = session_id
246
+ baggage.set_baggage(f"execution.{traceparent}", session_id)
247
+
248
+ metadata["observe"] = observe_meta
249
+
250
+ # Write back metadata (pydantic models are mutable by default in v2)
251
+ try:
252
+ request.metadata = metadata
253
+ except Exception:
254
+ # Fallback
255
+ request = request.model_copy(update={"metadata": metadata})
256
+
257
+ # Call through without transport-specific kwargs
258
+ return await original_srpc_transport_send_message(
259
+ self, request, *args, **kwargs
260
+ )
261
+
262
+ SRPCTransport.send_message = instrumented_srpc_transport_send_message
263
+
264
+ # send_message_streaming
265
+ original_srpc_transport_send_message_streaming = (
266
+ SRPCTransport.send_message_streaming
267
+ )
268
+
269
+ @functools.wraps(original_srpc_transport_send_message_streaming)
270
+ async def instrumented_srpc_transport_send_message_streaming(
271
+ self, request, *args, **kwargs
272
+ ):
273
+ # Put context into A2A message metadata instead of HTTP headers
274
+ with _global_tracer.start_as_current_span(
275
+ "slima2a.send_message_streaming"
276
+ ):
277
+ traceparent = get_current_traceparent()
278
+ session_id = None
279
+ if traceparent:
280
+ session_id = kv_store.get(f"execution.{traceparent}")
281
+ if not session_id:
282
+ session_id = get_value("session.id")
283
+ if session_id:
284
+ kv_store.set(f"execution.{traceparent}", session_id)
285
+
286
+ # Ensure metadata dict exists
287
+ try:
288
+ md = getattr(request.params, "metadata", None)
289
+ except AttributeError:
290
+ md = None
291
+ metadata = md if isinstance(md, dict) else {}
292
+
293
+ observe_meta = dict(metadata.get("observe", {}))
294
+
295
+ # Inject W3C trace context + baggage into observe_meta
296
+ TraceContextTextMapPropagator().inject(carrier=observe_meta)
297
+ W3CBaggagePropagator().inject(carrier=observe_meta)
298
+
299
+ if traceparent:
300
+ observe_meta["traceparent"] = traceparent
301
+ if session_id:
302
+ observe_meta["session_id"] = session_id
303
+ baggage.set_baggage(f"execution.{traceparent}", session_id)
304
+
305
+ metadata["observe"] = observe_meta
306
+
307
+ # Write back metadata (pydantic models are mutable by default in v2)
308
+ try:
309
+ request.metadata = metadata
310
+ except Exception:
311
+ # Fallback
312
+ request = request.model_copy(update={"metadata": metadata})
313
+
314
+ # Call through without transport-specific kwargs
315
+ return await original_srpc_transport_send_message_streaming(
316
+ self, request, *args, **kwargs
317
+ )
318
+
319
+ SRPCTransport.send_message_streaming = (
320
+ instrumented_srpc_transport_send_message_streaming
321
+ )
322
+
207
323
  def _uninstrument(self, **kwargs):
208
324
  import importlib
209
325
 
@@ -227,3 +343,20 @@ class A2AInstrumentor(BaseInstrumentor):
227
343
  DefaultRequestHandler.on_message_send = (
228
344
  DefaultRequestHandler.on_message_send.__wrapped__
229
345
  )
346
+
347
+ # handle slima2a
348
+ if importlib.util.find_spec("slima2a"):
349
+ from slima2a.client_transport import SRPCTransport
350
+
351
+ # Uninstrument `send_message`
352
+ if hasattr(SRPCTransport, "send_message") and hasattr(
353
+ SRPCTransport.send_message, "__wrapped__"
354
+ ):
355
+ SRPCTransport.send_message = SRPCTransport.send_message.__wrapped__
356
+
357
+ if hasattr(SRPCTransport, "send_message_streaming") and hasattr(
358
+ SRPCTransport.send_message_streaming, "__wrapped__"
359
+ ):
360
+ SRPCTransport.send_message_streaming = (
361
+ SRPCTransport.send_message.__wrapped__
362
+ )
@@ -531,7 +531,15 @@ class SLIMInstrumentor(BaseInstrumentor):
531
531
  if timeout is not None:
532
532
  kwargs["timeout"] = timeout
533
533
 
534
- result = await original_get_message(self, **kwargs)
534
+ if _global_tracer:
535
+ with _global_tracer.start_as_current_span(
536
+ "session.get_message"
537
+ ) as span:
538
+ if hasattr(self, "id"):
539
+ span.set_attribute("slim.session.id", str(self.id))
540
+ result = await original_get_message(self, **kwargs)
541
+ else:
542
+ result = await original_get_message(self, **kwargs)
535
543
 
536
544
  # Handle different return types from get_message
537
545
  if result is None:
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import re
10
+ import threading
10
11
  import time
11
12
  import uuid
12
13
 
@@ -64,6 +65,9 @@ from typing import Callable, Dict, Optional, Set
64
65
  TRACER_NAME = "ioa.observe.tracer"
65
66
  APP_NAME = ""
66
67
 
68
+ SESSION_IDLE_TIMEOUT_SECONDS = 300 # e.g. 5 minutes
69
+ SESSION_WATCHER_INTERVAL_SECONDS = 30
70
+
67
71
 
68
72
  def determine_reliability_score(span):
69
73
  if "observe.entity.output" in span.attributes:
@@ -132,6 +136,9 @@ class TracerWrapper(object):
132
136
  if not TracerWrapper.endpoint:
133
137
  return obj
134
138
 
139
+ # session activity tracking
140
+ obj._session_last_activity: dict[str, float] = {}
141
+ obj._session_lock = threading.Lock()
135
142
  obj.__image_uploader = image_uploader
136
143
  # {(agent_name): [success_count, total_count]}
137
144
  obj._agent_execution_counts = {}
@@ -270,12 +277,70 @@ class TracerWrapper(object):
270
277
 
271
278
  obj.__content_allow_list = ContentAllowList()
272
279
 
280
+ # start background watcher for session end
281
+ obj._start_session_watcher()
282
+
273
283
  # Force flushes for debug environments (e.g. local development)
274
284
  atexit.register(obj.exit_handler)
275
285
 
276
286
  return cls.instance
277
287
 
288
+ def _start_session_watcher(self) -> None:
289
+ t = threading.Thread(
290
+ target=self._session_watcher_loop,
291
+ name="ioa-session-watcher",
292
+ daemon=True,
293
+ )
294
+ t.start()
295
+
296
+ def _session_watcher_loop(self) -> None:
297
+ while True:
298
+ time.sleep(SESSION_WATCHER_INTERVAL_SECONDS)
299
+ now = time.time()
300
+ expired: dict[str, float] = {}
301
+
302
+ # Find idle sessions and remove them from _session_last_activity
303
+ with self._session_lock:
304
+ for session_id, last_ts in list(self._session_last_activity.items()):
305
+ if now - last_ts > SESSION_IDLE_TIMEOUT_SECONDS:
306
+ expired[session_id] = last_ts
307
+ del self._session_last_activity[session_id]
308
+
309
+ if not expired:
310
+ continue
311
+
312
+ tracer = self.get_tracer()
313
+
314
+ # Iterate over a snapshot and do *not* modify `expired` in the loop
315
+ for session_id, _last_ts in list(expired.items()):
316
+ print("ending session", session_id)
317
+ with tracer.start_as_current_span("session.end") as span:
318
+ span.set_attribute("session.id", session_id)
319
+ workflow_name = get_value("workflow_name")
320
+ if workflow_name:
321
+ span.set_attribute(OBSERVE_WORKFLOW_NAME, workflow_name)
322
+ span.set_attribute("session.ended_at", _last_ts)
323
+
324
+ # ensure end spans are exported reasonably fast
325
+ self.flush()
326
+
278
327
  def exit_handler(self):
328
+ # emit end spans for any sessions that never went idle
329
+ now = time.time()
330
+ tracer = self.get_tracer()
331
+
332
+ with self._session_lock:
333
+ remaining_ids = list(self._session_last_activity.keys())
334
+ self._session_last_activity.clear()
335
+
336
+ for session_id in remaining_ids:
337
+ with tracer.start_as_current_span("session.end") as span:
338
+ span.set_attribute("session.id", session_id)
339
+ workflow_name = get_value("workflow_name")
340
+ if workflow_name:
341
+ span.set_attribute(OBSERVE_WORKFLOW_NAME, workflow_name)
342
+ span.set_attribute("session.ended_at", now)
343
+
279
344
  self.flush()
280
345
 
281
346
  def _span_processor_on_start(self, span, parent_context):
@@ -362,6 +427,12 @@ class TracerWrapper(object):
362
427
  # Mark this span as processed
363
428
  self._processed_spans.add(span_id)
364
429
 
430
+ # update last activity per session
431
+ session_id = span.attributes.get("session.id")
432
+ if session_id:
433
+ with self._session_lock:
434
+ self._session_last_activity[session_id] = time.time()
435
+
365
436
  determine_reliability_score(span)
366
437
  # start_time = span.attributes.get("ioa_start_time")
367
438
 
@@ -382,10 +453,6 @@ class TracerWrapper(object):
382
453
  except Exception as e:
383
454
  logging.error(f"Error applying span transformation: {e}")
384
455
 
385
- # if start_time is not None:
386
- # latency = (time.time() - start_time) * 1000
387
- # self.response_latency_histogram.record(latency, attributes=span.attributes)
388
-
389
456
  # Call original on_end method if it exists
390
457
  if (
391
458
  hasattr(self, "_TracerWrapper__spans_processor_original_on_end")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ioa-observe-sdk
3
- Version: 1.0.26
3
+ Version: 1.0.27
4
4
  Summary: IOA Observability SDK
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -14,10 +14,10 @@ ioa_observe/sdk/decorators/base.py,sha256=gezoLMLOo-CWsxzsXbQMsj5HxmchUEmlhTspbq
14
14
  ioa_observe/sdk/decorators/helpers.py,sha256=I9HXMBivkZpGDtPe9Ad_UU35p_m_wEPate4r_fU0oOA,2705
15
15
  ioa_observe/sdk/decorators/util.py,sha256=IebvH9gwZN1en3LblYJUh4bAV2STl6xmp8WpZzBDH2g,30068
16
16
  ioa_observe/sdk/instrumentations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- ioa_observe/sdk/instrumentations/a2a.py,sha256=U6VbhgEMJVbMXugq0Ap07EFSvBPjvS0rX80-KIAKi4w,9356
17
+ ioa_observe/sdk/instrumentations/a2a.py,sha256=LHvvQUluPtmvbRNuoqf-EkWlZJa_NicerTpM3t1UZws,15299
18
18
  ioa_observe/sdk/instrumentations/mcp.py,sha256=UG82fWitFT-K-ecnkTKZa-uFTDAs-j2HymvhUwCBRLY,18721
19
19
  ioa_observe/sdk/instrumentations/nats.py,sha256=MEYCFqHyRK5HtkjrbGVV5qIWplZ1ZjJjFCB8vtQl6qE,14736
20
- ioa_observe/sdk/instrumentations/slim.py,sha256=1zVPqInz7SOZ54fwy9V6aR9Lb8KpiGsXIxV_0ou14LE,45339
20
+ ioa_observe/sdk/instrumentations/slim.py,sha256=c_Q1cb6pdjZLOw1MbclUf1eRFrc_sUEe9pQJvAyoD1Y,45712
21
21
  ioa_observe/sdk/logging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  ioa_observe/sdk/logging/logging.py,sha256=HZxW9s8Due7jgiNkdI38cIjv5rC9D-Flta3RQMOnpow,2891
23
23
  ioa_observe/sdk/metrics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -35,15 +35,15 @@ ioa_observe/sdk/tracing/content_allow_list.py,sha256=1fAkpIwUQ7vDwCTkIVrqeltWQtr
35
35
  ioa_observe/sdk/tracing/context_manager.py,sha256=O0JEXYa9h8anhW78R8KKBuqS0j4by1E1KXxNIMPnLr8,400
36
36
  ioa_observe/sdk/tracing/context_utils.py,sha256=-sYS9vPLI87davV9ubneq5xqbV583CC_c0SmOQS1TAs,2933
37
37
  ioa_observe/sdk/tracing/manual.py,sha256=KS6WN-zw9vAACzXYmnMoJm9d1fenYMfvzeK1GrGDPDE,1937
38
- ioa_observe/sdk/tracing/tracing.py,sha256=lX3Mh7NH2Rxtj3Bs6Oofxedd8YC_Y4gg3gMl0puFfSI,47357
38
+ ioa_observe/sdk/tracing/tracing.py,sha256=7eJAEqaAEBZ2gjIXrdb7SyUidA8P10GnpBLzr8InLKI,50007
39
39
  ioa_observe/sdk/tracing/transform_span.py,sha256=XTApi_gJxum7ynvhtcoCfDyK8VVOj91Q1DT6hAeLHA8,8419
40
40
  ioa_observe/sdk/utils/__init__.py,sha256=UPn182U-UblF_XwXaFpx8F-TmQTbm1LYf9y89uSp5Hw,704
41
41
  ioa_observe/sdk/utils/const.py,sha256=d67dUTAH9UpWvUV9GLBUqn1Sc2knJ55dy-e6YoLrvSo,1318
42
42
  ioa_observe/sdk/utils/in_memory_span_exporter.py,sha256=H_4TRaThMO1H6vUQ0OpQvzJk_fZH0OOsRAM1iZQXsR8,2112
43
43
  ioa_observe/sdk/utils/json_encoder.py,sha256=g4NQ0tTqgWssY6I1D7r4zo0G6PiUo61jhofTAw5-jno,639
44
44
  ioa_observe/sdk/utils/package_check.py,sha256=1d1MjxhwoEZIx9dumirT2pRsEWgn-m-SI4npDeEalew,576
45
- ioa_observe_sdk-1.0.26.dist-info/licenses/LICENSE.md,sha256=55VjUfgjWOS4vv3Cf55gfq-RxjPgRIO2vlgYPUuC5lA,11362
46
- ioa_observe_sdk-1.0.26.dist-info/METADATA,sha256=xpBRssD4PqtdIIlXpIvS_joVzQvA8XsUUx_GrF31xa8,7997
47
- ioa_observe_sdk-1.0.26.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
48
- ioa_observe_sdk-1.0.26.dist-info/top_level.txt,sha256=Yt-6Y1olZEDqCs2REeqI30WjYx0pLGQSVqzYmDd67N8,12
49
- ioa_observe_sdk-1.0.26.dist-info/RECORD,,
45
+ ioa_observe_sdk-1.0.27.dist-info/licenses/LICENSE.md,sha256=55VjUfgjWOS4vv3Cf55gfq-RxjPgRIO2vlgYPUuC5lA,11362
46
+ ioa_observe_sdk-1.0.27.dist-info/METADATA,sha256=OrhfkqzR0Qj2qwSGXtSmL9rYjS1OMk-IYmTU5wHMHdE,7997
47
+ ioa_observe_sdk-1.0.27.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
48
+ ioa_observe_sdk-1.0.27.dist-info/top_level.txt,sha256=Yt-6Y1olZEDqCs2REeqI30WjYx0pLGQSVqzYmDd67N8,12
49
+ ioa_observe_sdk-1.0.27.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5