ioa-observe-sdk 1.0.20__py3-none-any.whl → 1.0.22__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,324 @@
1
+ # Copyright AGNTCY Contributors (https://github.com/agntcy)
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from typing import Collection
5
+ import functools
6
+ import json
7
+ import base64
8
+ import threading
9
+
10
+ from opentelemetry import baggage, context
11
+ from opentelemetry.baggage.propagation import W3CBaggagePropagator
12
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
13
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
14
+
15
+ from ioa_observe.sdk import TracerWrapper
16
+ from ioa_observe.sdk.client import kv_store
17
+ from ioa_observe.sdk.tracing import set_session_id, get_current_traceparent
18
+
19
+ _instruments = ("nats-py >= 2.10.0",)
20
+ _global_tracer = None
21
+ _kv_lock = threading.RLock() # Add thread-safety for kv_store operations
22
+
23
+
24
+ class NATSInstrumentor(BaseInstrumentor):
25
+ def __init__(self):
26
+ super().__init__()
27
+ global _global_tracer
28
+ _global_tracer = TracerWrapper().get_tracer()
29
+
30
+ def instrumentation_dependencies(self) -> Collection[str]:
31
+ return _instruments
32
+
33
+ def _instrument(self, **kwargs):
34
+ try:
35
+ import nats
36
+ except ImportError:
37
+ raise ImportError("No module named 'nats'. Please install it first.")
38
+
39
+ # Instrument `publish` method
40
+ original_publish = nats.NATS.publish
41
+
42
+ @functools.wraps(original_publish)
43
+ async def instrumented_publish(self, *args, **kwargs):
44
+ if _global_tracer:
45
+ with _global_tracer.start_as_current_span("nats.publish") as span:
46
+ traceparent = get_current_traceparent()
47
+ span.set_attribute("nats.topic", args[0] if args else None)
48
+ else:
49
+ traceparent = get_current_traceparent()
50
+
51
+ # Thread-safe access to kv_store
52
+ session_id = None
53
+ if traceparent:
54
+ with _kv_lock:
55
+ session_id = kv_store.get(f"execution.{traceparent}")
56
+ if session_id:
57
+ kv_store.set(f"execution.{traceparent}", session_id)
58
+
59
+ headers = {
60
+ "session_id": session_id if session_id else None,
61
+ "traceparent": traceparent,
62
+ }
63
+
64
+ # Set baggage context
65
+ if traceparent and session_id:
66
+ baggage.set_baggage(f"execution.{traceparent}", session_id)
67
+
68
+ # Wrap message with headers - handle different message positions
69
+ message_arg_index = 1 # message will typically be the second argument
70
+ if len(args) > message_arg_index:
71
+ original_args = list(args)
72
+ message = original_args[message_arg_index]
73
+ wrapped_message = NATSInstrumentor._wrap_message_with_headers(
74
+ self, message, headers
75
+ )
76
+
77
+ # Convert wrapped message back to bytes if needed
78
+ if isinstance(wrapped_message, dict):
79
+ message_to_send = json.dumps(wrapped_message).encode("utf-8")
80
+ else:
81
+ message_to_send = wrapped_message
82
+
83
+ original_args[message_arg_index] = message_to_send
84
+ args = tuple(original_args)
85
+
86
+ return await original_publish(self, *args, **kwargs)
87
+
88
+ nats.NATS.publish = instrumented_publish
89
+
90
+ # Instrument `request` method
91
+ original_request = nats.NATS.request
92
+
93
+ @functools.wraps(original_request)
94
+ async def instrumented_request(self, *args, **kwargs):
95
+ if _global_tracer:
96
+ with _global_tracer.start_as_current_span("nats.request") as span:
97
+ traceparent = get_current_traceparent()
98
+ span.set_attribute("nats.topic", args[0] if args else None)
99
+ else:
100
+ traceparent = get_current_traceparent()
101
+
102
+ # Thread-safe access to kv_store
103
+ session_id = None
104
+ if traceparent:
105
+ with _kv_lock:
106
+ session_id = kv_store.get(f"execution.{traceparent}")
107
+ if session_id:
108
+ kv_store.set(f"execution.{traceparent}", session_id)
109
+
110
+ headers = {
111
+ "session_id": session_id if session_id else None,
112
+ "traceparent": traceparent,
113
+ }
114
+
115
+ # Set baggage context
116
+ if traceparent and session_id:
117
+ baggage.set_baggage(f"execution.{traceparent}", session_id)
118
+
119
+ # Wrap message with headers - handle different message positions
120
+ message_arg_index = 1 # message will typically be the second argument
121
+ if len(args) > message_arg_index:
122
+ original_args = list(args)
123
+ message = original_args[message_arg_index]
124
+ wrapped_message = NATSInstrumentor._wrap_message_with_headers(
125
+ self, message, headers
126
+ )
127
+
128
+ # Convert wrapped message back to bytes if needed
129
+ if isinstance(wrapped_message, dict):
130
+ message_to_send = json.dumps(wrapped_message).encode("utf-8")
131
+ else:
132
+ message_to_send = wrapped_message
133
+
134
+ original_args[message_arg_index] = message_to_send
135
+ args = tuple(original_args)
136
+
137
+ return await original_request(self, *args, **kwargs)
138
+
139
+ nats.NATS.request = instrumented_request
140
+
141
+ # Instrument `subscribe` method
142
+ original_subscribe = nats.NATS.subscribe
143
+
144
+ @functools.wraps(original_subscribe)
145
+ async def instrumented_subscribe(self, subject, cb=None, *args, **kwargs):
146
+ # Wrap the callback to add tracing spans for message handling
147
+ if (
148
+ cb is not None
149
+ and _global_tracer
150
+ and not getattr(cb, "_is_instrumented", False)
151
+ ):
152
+ user_cb = cb # SAVE the original callback
153
+
154
+ @functools.wraps(user_cb)
155
+ async def traced_callback(msg):
156
+ try:
157
+ message_dict = json.loads(msg.data.decode())
158
+ headers = message_dict.get("headers", {})
159
+
160
+ # Extract traceparent and session info from headers
161
+ traceparent = headers.get("traceparent")
162
+ session_id = headers.get("session_id")
163
+
164
+ # Create carrier for context propagation
165
+ carrier = {}
166
+ for key in ["traceparent", "Traceparent", "baggage", "Baggage"]:
167
+ if key.lower() in [k.lower() for k in headers.keys()]:
168
+ for k in headers.keys():
169
+ if k.lower() == key.lower():
170
+ carrier[key.lower()] = headers[k]
171
+
172
+ # Restore trace context
173
+ ctx = None
174
+ if carrier and traceparent:
175
+ ctx = TraceContextTextMapPropagator().extract(
176
+ carrier=carrier
177
+ )
178
+ ctx = W3CBaggagePropagator().extract(
179
+ carrier=carrier, context=ctx
180
+ )
181
+
182
+ # Activate the restored context
183
+ token = context.attach(ctx)
184
+
185
+ try:
186
+ # Set execution ID with the restored context
187
+ if session_id and session_id != "None":
188
+ set_session_id(session_id, traceparent=traceparent)
189
+
190
+ # Store in kv_store with thread safety
191
+ with _kv_lock:
192
+ kv_store.set(
193
+ f"execution.{traceparent}", session_id
194
+ )
195
+
196
+ # DON'T detach the context yet - we need it to persist for the callback
197
+ # The context will be cleaned up later or by the garbage collector
198
+
199
+ except Exception as e:
200
+ # Only detach on error
201
+ context.detach(token)
202
+ raise e
203
+ elif traceparent and session_id and session_id != "None":
204
+ # Even without carrier context, set session ID if we have the data
205
+ set_session_id(session_id, traceparent=traceparent)
206
+
207
+ # Fallback: check stored execution ID if not found in headers
208
+ if traceparent and (not session_id or session_id == "None"):
209
+ with _kv_lock:
210
+ stored_session_id = kv_store.get(
211
+ f"execution.{traceparent}"
212
+ )
213
+ if stored_session_id:
214
+ session_id = stored_session_id
215
+ set_session_id(session_id, traceparent=traceparent)
216
+
217
+ # Process and clean the message
218
+ message_to_return = message_dict.copy()
219
+ if "headers" in message_to_return:
220
+ headers_copy = message_to_return["headers"].copy()
221
+ # Remove tracing-specific headers but keep other headers
222
+ headers_copy.pop("traceparent", None)
223
+ headers_copy.pop("session_id", None)
224
+ if headers_copy:
225
+ message_to_return["headers"] = headers_copy
226
+ else:
227
+ message_to_return.pop("headers", None)
228
+
229
+ # Return processed message, update msg.data
230
+ if isinstance(message_to_return, str):
231
+ msg.data = message_to_return.encode("utf-8")
232
+ else:
233
+ msg.data = json.dumps(message_to_return).encode("utf-8")
234
+
235
+ # Now call the original user callback with the modified msg
236
+ ctx = {} if ctx is None else ctx
237
+ if _global_tracer:
238
+ with _global_tracer.start_as_current_span(
239
+ "nats.subscribe.callback", context=ctx
240
+ ) as span:
241
+ span.set_attribute("nats.subject", subject)
242
+ span.set_attribute("nats.session_id", session_id)
243
+ await user_cb(msg)
244
+ else:
245
+ await user_cb(msg)
246
+ except Exception as e:
247
+ print(f"Error processing message in traced_callback: {e}")
248
+ await user_cb(msg) # Call original callback even on error
249
+
250
+ traced_callback._is_instrumented = True # mark as instrumented
251
+ cb = traced_callback
252
+
253
+ return await original_subscribe(self, subject, cb=cb, *args, **kwargs)
254
+
255
+ nats.NATS.subscribe = instrumented_subscribe
256
+
257
+ def _wrap_message_with_headers(self, message, headers):
258
+ """Helper method to wrap messages with headers consistently"""
259
+ if isinstance(message, bytes):
260
+ try:
261
+ decoded_message = message.decode("utf-8")
262
+ try:
263
+ original_message = json.loads(decoded_message)
264
+ if isinstance(original_message, dict):
265
+ wrapped_message = original_message.copy()
266
+ existing_headers = wrapped_message.get("headers", {})
267
+ existing_headers.update(headers)
268
+ wrapped_message["headers"] = existing_headers
269
+ else:
270
+ wrapped_message = {
271
+ "headers": headers,
272
+ "payload": original_message,
273
+ }
274
+ except json.JSONDecodeError:
275
+ wrapped_message = {"headers": headers, "payload": decoded_message}
276
+ except UnicodeDecodeError:
277
+ # Fix type annotation issue by ensuring message is bytes
278
+ encoded_message = (
279
+ message if isinstance(message, bytes) else message.encode("utf-8")
280
+ )
281
+ wrapped_message = {
282
+ "headers": headers,
283
+ "payload": base64.b64encode(encoded_message).decode("utf-8"),
284
+ }
285
+ elif isinstance(message, str):
286
+ try:
287
+ original_message = json.loads(message)
288
+ if isinstance(original_message, dict):
289
+ wrapped_message = original_message.copy()
290
+ existing_headers = wrapped_message.get("headers", {})
291
+ existing_headers.update(headers)
292
+ wrapped_message["headers"] = existing_headers
293
+ else:
294
+ wrapped_message = {"headers": headers, "payload": original_message}
295
+ except json.JSONDecodeError:
296
+ wrapped_message = {"headers": headers, "payload": message}
297
+ elif isinstance(message, dict):
298
+ wrapped_message = message.copy()
299
+ existing_headers = wrapped_message.get("headers", {})
300
+ existing_headers.update(headers)
301
+ wrapped_message["headers"] = existing_headers
302
+ else:
303
+ wrapped_message = {"headers": headers, "payload": json.dumps(message)}
304
+
305
+ return wrapped_message
306
+
307
+ def _uninstrument(self, **kwargs):
308
+ try:
309
+ import nats
310
+ except ImportError:
311
+ raise ImportError("No module named 'nats'. Please install it first.")
312
+
313
+ # Restore the original methods
314
+ methods_to_restore = [
315
+ "publish",
316
+ "request",
317
+ "subscribe",
318
+ ]
319
+
320
+ for method_name in methods_to_restore:
321
+ if hasattr(nats.NATS, method_name):
322
+ original_method = getattr(nats.NATS, method_name)
323
+ if hasattr(original_method, "__wrapped__"):
324
+ setattr(nats.NATS, method_name, original_method.__wrapped__)
@@ -16,7 +16,7 @@ from ioa_observe.sdk import TracerWrapper
16
16
  from ioa_observe.sdk.client import kv_store
17
17
  from ioa_observe.sdk.tracing import set_session_id, get_current_traceparent
18
18
 
19
- _instruments = ("slim-bindings >= 0.4",)
19
+ _instruments = ("slim-bindings >= 0.4.0",)
20
20
  _global_tracer = None
21
21
  _kv_lock = threading.RLock() # Add thread-safety for kv_store operations
22
22
 
@@ -39,7 +39,9 @@ class SLIMInstrumentor(BaseInstrumentor):
39
39
  )
40
40
 
41
41
  # Instrument `publish` method - handles multiple signatures
42
+ # In v0.6.0+, publish moved from Slim class to Session objects
42
43
  if hasattr(slim_bindings.Slim, "publish"):
44
+ # Legacy v0.5.x app-level publish method
43
45
  original_publish = slim_bindings.Slim.publish
44
46
 
45
47
  @functools.wraps(original_publish)
@@ -166,7 +168,7 @@ class SLIMInstrumentor(BaseInstrumentor):
166
168
 
167
169
  slim_bindings.Slim.publish_to = instrumented_publish_to
168
170
 
169
- # Instrument `request_reply` (new v0.4.0+ method)
171
+ # Instrument `request_reply` (v0.4.0+ to v0.5.x method, removed in v0.6.0)
170
172
  if hasattr(slim_bindings.Slim, "request_reply"):
171
173
  original_request_reply = slim_bindings.Slim.request_reply
172
174
 
@@ -290,35 +292,261 @@ class SLIMInstrumentor(BaseInstrumentor):
290
292
 
291
293
  slim_bindings.Slim.set_route = instrumented_set_route
292
294
 
293
- # Instrument `receive`
294
- original_receive = slim_bindings.Slim.receive
295
-
296
- @functools.wraps(original_receive)
297
- async def instrumented_receive(
298
- self, session=None, timeout=None, *args, **kwargs
299
- ):
300
- # Handle both old and new API patterns
301
- if session is not None or timeout is not None:
302
- # New API pattern with session parameter
303
- kwargs_with_params = kwargs.copy()
304
- if session is not None:
305
- kwargs_with_params["session"] = session
306
- if timeout is not None:
307
- kwargs_with_params["timeout"] = timeout
308
- recv_session, raw_message = await original_receive(
309
- self, **kwargs_with_params
310
- )
295
+ # Instrument `receive` - only if it exists (removed in v0.6.0)
296
+ if hasattr(slim_bindings.Slim, "receive"):
297
+ original_receive = slim_bindings.Slim.receive
298
+
299
+ @functools.wraps(original_receive)
300
+ async def instrumented_receive(
301
+ self, session=None, timeout=None, *args, **kwargs
302
+ ):
303
+ # Handle both old and new API patterns
304
+ if session is not None or timeout is not None:
305
+ # New API pattern with session parameter
306
+ kwargs_with_params = kwargs.copy()
307
+ if session is not None:
308
+ kwargs_with_params["session"] = session
309
+ if timeout is not None:
310
+ kwargs_with_params["timeout"] = timeout
311
+ recv_session, raw_message = await original_receive(
312
+ self, **kwargs_with_params
313
+ )
314
+ else:
315
+ # Legacy API pattern
316
+ recv_session, raw_message = await original_receive(
317
+ self, *args, **kwargs
318
+ )
319
+
320
+ if raw_message is None:
321
+ return recv_session, raw_message
322
+
323
+ try:
324
+ message_dict = json.loads(raw_message.decode())
325
+ headers = message_dict.get("headers", {})
326
+
327
+ # Extract traceparent and session info from headers
328
+ traceparent = headers.get("traceparent")
329
+ session_id = headers.get("session_id")
330
+
331
+ # Create carrier for context propagation
332
+ carrier = {}
333
+ for key in ["traceparent", "Traceparent", "baggage", "Baggage"]:
334
+ if key.lower() in [k.lower() for k in headers.keys()]:
335
+ for k in headers.keys():
336
+ if k.lower() == key.lower():
337
+ carrier[key.lower()] = headers[k]
338
+
339
+ # Restore trace context
340
+ if carrier and traceparent:
341
+ ctx = TraceContextTextMapPropagator().extract(carrier=carrier)
342
+ ctx = W3CBaggagePropagator().extract(
343
+ carrier=carrier, context=ctx
344
+ )
345
+
346
+ # Activate the restored context
347
+ token = context.attach(ctx)
348
+
349
+ try:
350
+ # Set execution ID with the restored context
351
+ if session_id and session_id != "None":
352
+ set_session_id(session_id, traceparent=traceparent)
353
+
354
+ # Store in kv_store with thread safety
355
+ with _kv_lock:
356
+ kv_store.set(f"execution.{traceparent}", session_id)
357
+
358
+ # DON'T detach the context yet - we need it to persist for the callback
359
+ # The context will be cleaned up later or by the garbage collector
360
+
361
+ except Exception as e:
362
+ # Only detach on error
363
+ context.detach(token)
364
+ raise e
365
+ elif traceparent and session_id and session_id != "None":
366
+ # Even without carrier context, set session ID if we have the data
367
+ set_session_id(session_id, traceparent=traceparent)
368
+
369
+ # Fallback: check stored execution ID if not found in headers
370
+ if traceparent and (not session_id or session_id == "None"):
371
+ with _kv_lock:
372
+ stored_session_id = kv_store.get(f"execution.{traceparent}")
373
+ if stored_session_id:
374
+ session_id = stored_session_id
375
+ set_session_id(session_id, traceparent=traceparent)
376
+
377
+ # Process and clean the message
378
+ message_to_return = message_dict.copy()
379
+ if "headers" in message_to_return:
380
+ headers_copy = message_to_return["headers"].copy()
381
+ # Remove tracing-specific headers but keep other headers
382
+ headers_copy.pop("traceparent", None)
383
+ headers_copy.pop("session_id", None)
384
+ headers_copy.pop("slim_session_id", None)
385
+ if headers_copy:
386
+ message_to_return["headers"] = headers_copy
387
+ else:
388
+ message_to_return.pop("headers", None)
389
+
390
+ # Return processed message
391
+ if len(message_to_return) == 1 and "payload" in message_to_return:
392
+ payload = message_to_return["payload"]
393
+ if isinstance(payload, str):
394
+ try:
395
+ payload_dict = json.loads(payload)
396
+ return recv_session, json.dumps(payload_dict).encode(
397
+ "utf-8"
398
+ )
399
+ except json.JSONDecodeError:
400
+ return recv_session, payload.encode(
401
+ "utf-8"
402
+ ) if isinstance(payload, str) else payload
403
+ return recv_session, json.dumps(payload).encode(
404
+ "utf-8"
405
+ ) if isinstance(payload, (dict, list)) else payload
406
+ else:
407
+ return recv_session, json.dumps(message_to_return).encode(
408
+ "utf-8"
409
+ )
410
+
411
+ except Exception as e:
412
+ print(f"Error processing message: {e}")
413
+ return recv_session, raw_message
414
+
415
+ slim_bindings.Slim.receive = instrumented_receive
416
+
417
+ # Instrument `connect` - only if it exists
418
+ if hasattr(slim_bindings.Slim, "connect"):
419
+ original_connect = slim_bindings.Slim.connect
420
+
421
+ @functools.wraps(original_connect)
422
+ async def instrumented_connect(self, *args, **kwargs):
423
+ if _global_tracer:
424
+ with _global_tracer.start_as_current_span("slim.connect"):
425
+ return await original_connect(self, *args, **kwargs)
426
+ else:
427
+ return await original_connect(self, *args, **kwargs)
428
+
429
+ slim_bindings.Slim.connect = instrumented_connect
430
+
431
+ # Instrument `create_session` (new v0.4.0+ method)
432
+ if hasattr(slim_bindings.Slim, "create_session"):
433
+ original_create_session = slim_bindings.Slim.create_session
434
+
435
+ @functools.wraps(original_create_session)
436
+ async def instrumented_create_session(self, config, *args, **kwargs):
437
+ if _global_tracer:
438
+ with _global_tracer.start_as_current_span(
439
+ "slim.create_session"
440
+ ) as span:
441
+ session_info = await original_create_session(
442
+ self, config, *args, **kwargs
443
+ )
444
+
445
+ # Add session attributes to span
446
+ if hasattr(session_info, "id"):
447
+ span.set_attribute("slim.session.id", str(session_info.id))
448
+
449
+ return session_info
450
+ else:
451
+ return await original_create_session(self, config, *args, **kwargs)
452
+
453
+ slim_bindings.Slim.create_session = instrumented_create_session
454
+
455
+ # Instrument new v0.6.0+ session-level methods
456
+ # These methods are available on Session objects, not the Slim app
457
+ self._instrument_session_methods(slim_bindings)
458
+
459
+ # Instrument new v0.6.0+ app-level methods
460
+ # listen_for_session replaces app.receive() for new sessions in v0.6.0+
461
+ if hasattr(slim_bindings.Slim, "listen_for_session"):
462
+ original_listen_for_session = slim_bindings.Slim.listen_for_session
463
+
464
+ @functools.wraps(original_listen_for_session)
465
+ async def instrumented_listen_for_session(self, *args, **kwargs):
466
+ if _global_tracer:
467
+ with _global_tracer.start_as_current_span(
468
+ "slim.listen_for_session"
469
+ ):
470
+ session = await original_listen_for_session(
471
+ self, *args, **kwargs
472
+ )
473
+
474
+ return session
475
+ else:
476
+ return await original_listen_for_session(self, *args, **kwargs)
477
+
478
+ slim_bindings.Slim.listen_for_session = instrumented_listen_for_session
479
+
480
+ def _instrument_session_methods(self, slim_bindings):
481
+ # In v0.6.0+, we need to instrument session classes dynamically
482
+ # Try to find session-related classes in the slim_bindings module
483
+ session_classes = []
484
+
485
+ # Look for common session class names
486
+ for attr_name in ["Session", "P2PSession", "GroupSession"]:
487
+ if hasattr(slim_bindings, attr_name):
488
+ session_class = getattr(slim_bindings, attr_name)
489
+ session_classes.append((attr_name, session_class))
490
+
491
+ # Also look for any class that has session-like methods
492
+ for attr_name in dir(slim_bindings):
493
+ attr = getattr(slim_bindings, attr_name)
494
+ if isinstance(attr, type) and (
495
+ hasattr(attr, "get_message") or hasattr(attr, "publish")
496
+ ):
497
+ session_classes.append((attr_name, attr))
498
+
499
+ # Instrument session methods for found classes
500
+ for _, session_class in session_classes:
501
+ # Instrument get_message (v0.6.0+ replacement for receive)
502
+ if hasattr(session_class, "get_message"):
503
+ self._instrument_session_get_message(session_class)
504
+
505
+ # Instrument session publish methods
506
+ if hasattr(session_class, "publish"):
507
+ self._instrument_session_publish(session_class, "publish")
508
+
509
+ if hasattr(session_class, "publish_to"):
510
+ self._instrument_session_publish(session_class, "publish_to")
511
+
512
+ def _instrument_session_get_message(self, session_class):
513
+ """Instrument session.get_message method (v0.6.0+)"""
514
+ original_get_message = session_class.get_message
515
+
516
+ @functools.wraps(original_get_message)
517
+ async def instrumented_get_message(self, timeout=None, *args, **kwargs):
518
+ # Handle the message reception similar to the old receive method
519
+ if timeout is not None:
520
+ kwargs["timeout"] = timeout
521
+
522
+ result = await original_get_message(self, **kwargs)
523
+
524
+ # Handle different return types from get_message
525
+ if result is None:
526
+ return result
527
+
528
+ # Check if get_message returns a tuple (context, message) or just message
529
+ if isinstance(result, tuple) and len(result) == 2:
530
+ message_context, raw_message = result
311
531
  else:
312
- # Legacy API pattern
313
- recv_session, raw_message = await original_receive(
314
- self, *args, **kwargs
315
- )
532
+ raw_message = result
533
+ message_context = None
316
534
 
317
535
  if raw_message is None:
318
- return recv_session, raw_message
536
+ return result
319
537
 
320
538
  try:
321
- message_dict = json.loads(raw_message.decode())
539
+ # Handle different message types
540
+ if isinstance(raw_message, bytes):
541
+ message_dict = json.loads(raw_message.decode())
542
+ elif isinstance(raw_message, str):
543
+ message_dict = json.loads(raw_message)
544
+ elif isinstance(raw_message, dict):
545
+ message_dict = raw_message
546
+ else:
547
+ # Unknown type, return as-is
548
+ return result
549
+
322
550
  headers = message_dict.get("headers", {})
323
551
 
324
552
  # Extract traceparent and session info from headers
@@ -351,7 +579,6 @@ class SLIMInstrumentor(BaseInstrumentor):
351
579
  kv_store.set(f"execution.{traceparent}", session_id)
352
580
 
353
581
  # DON'T detach the context yet - we need it to persist for the callback
354
- # The context will be cleaned up later or by the garbage collector
355
582
 
356
583
  except Exception as e:
357
584
  # Only detach on error
@@ -382,68 +609,257 @@ class SLIMInstrumentor(BaseInstrumentor):
382
609
  else:
383
610
  message_to_return.pop("headers", None)
384
611
 
385
- # Return processed message
612
+ # Return processed message, maintaining original return format
386
613
  if len(message_to_return) == 1 and "payload" in message_to_return:
387
614
  payload = message_to_return["payload"]
388
615
  if isinstance(payload, str):
389
616
  try:
390
617
  payload_dict = json.loads(payload)
391
- return recv_session, json.dumps(payload_dict).encode(
392
- "utf-8"
393
- )
618
+ processed_message = json.dumps(payload_dict).encode("utf-8")
394
619
  except json.JSONDecodeError:
395
- return recv_session, payload.encode("utf-8") if isinstance(
396
- payload, str
397
- ) else payload
398
- return recv_session, json.dumps(payload).encode(
399
- "utf-8"
400
- ) if isinstance(payload, (dict, list)) else payload
620
+ processed_message = (
621
+ payload.encode("utf-8")
622
+ if isinstance(payload, str)
623
+ else payload
624
+ )
625
+ else:
626
+ processed_message = (
627
+ json.dumps(payload).encode("utf-8")
628
+ if isinstance(payload, (dict, list))
629
+ else payload
630
+ )
401
631
  else:
402
- return recv_session, json.dumps(message_to_return).encode("utf-8")
632
+ processed_message = json.dumps(message_to_return).encode("utf-8")
633
+
634
+ # Return in the same format as received
635
+ if isinstance(result, tuple) and len(result) == 2:
636
+ return (message_context, processed_message)
637
+ else:
638
+ return processed_message
403
639
 
404
640
  except Exception as e:
405
641
  print(f"Error processing message: {e}")
406
- return recv_session, raw_message
642
+ return result
407
643
 
408
- slim_bindings.Slim.receive = instrumented_receive
644
+ session_class.get_message = instrumented_get_message
409
645
 
410
- # Instrument `connect`
411
- original_connect = slim_bindings.Slim.connect
646
+ def _instrument_session_publish(self, session_class, method_name):
647
+ """Instrument session publish methods"""
648
+ original_method = getattr(session_class, method_name)
412
649
 
413
- @functools.wraps(original_connect)
414
- async def instrumented_connect(self, *args, **kwargs):
650
+ @functools.wraps(original_method)
651
+ async def instrumented_session_publish(self, *args, **kwargs):
415
652
  if _global_tracer:
416
- with _global_tracer.start_as_current_span("slim.connect"):
417
- return await original_connect(self, *args, **kwargs)
653
+ with _global_tracer.start_as_current_span(
654
+ f"session.{method_name}"
655
+ ) as span:
656
+ traceparent = get_current_traceparent()
657
+
658
+ # Add session context to span
659
+ if hasattr(self, "id"):
660
+ span.set_attribute("slim.session.id", str(self.id))
661
+
662
+ # Handle message wrapping
663
+ if args:
664
+ # Thread-safe access to kv_store
665
+ session_id = None
666
+ if traceparent:
667
+ with _kv_lock:
668
+ session_id = kv_store.get(f"execution.{traceparent}")
669
+ if session_id:
670
+ kv_store.set(f"execution.{traceparent}", session_id)
671
+
672
+ headers = {
673
+ "session_id": session_id if session_id else None,
674
+ "traceparent": traceparent,
675
+ "slim_session_id": str(self.id)
676
+ if hasattr(self, "id")
677
+ else None,
678
+ }
679
+
680
+ # Set baggage context
681
+ if traceparent and session_id:
682
+ baggage.set_baggage(f"execution.{traceparent}", session_id)
683
+
684
+ # Wrap the message (first argument for publish, second for publish_to)
685
+ message_idx = 1 if method_name == "publish_to" else 0
686
+ if len(args) > message_idx:
687
+ args_list = list(args)
688
+ message = args_list[message_idx]
689
+ wrapped_message = (
690
+ SLIMInstrumentor._wrap_message_with_headers(
691
+ None, message, headers
692
+ )
693
+ )
694
+
695
+ # Convert wrapped message back to bytes if needed
696
+ if isinstance(wrapped_message, dict):
697
+ message_to_send = json.dumps(wrapped_message).encode(
698
+ "utf-8"
699
+ )
700
+ else:
701
+ message_to_send = wrapped_message
702
+
703
+ args_list[message_idx] = message_to_send
704
+ args = tuple(args_list)
705
+
706
+ return await original_method(self, *args, **kwargs)
418
707
  else:
419
- return await original_connect(self, *args, **kwargs)
708
+ # Handle message wrapping even without tracing
709
+ if args:
710
+ traceparent = get_current_traceparent()
711
+ session_id = None
712
+ if traceparent:
713
+ with _kv_lock:
714
+ session_id = kv_store.get(f"execution.{traceparent}")
715
+
716
+ if traceparent or session_id:
717
+ headers = {
718
+ "session_id": session_id if session_id else None,
719
+ "traceparent": traceparent,
720
+ "slim_session_id": str(self.id)
721
+ if hasattr(self, "id")
722
+ else None,
723
+ }
420
724
 
421
- slim_bindings.Slim.connect = instrumented_connect
725
+ # Wrap the message (first argument for publish, second for publish_to)
726
+ message_idx = 1 if method_name == "publish_to" else 0
727
+ if len(args) > message_idx:
728
+ args_list = list(args)
729
+ message = args_list[message_idx]
730
+ wrapped_message = (
731
+ SLIMInstrumentor._wrap_message_with_headers(
732
+ None, message, headers
733
+ )
734
+ )
422
735
 
423
- # Instrument `create_session` (new v0.4.0+ method)
424
- if hasattr(slim_bindings.Slim, "create_session"):
425
- original_create_session = slim_bindings.Slim.create_session
736
+ if isinstance(wrapped_message, dict):
737
+ message_to_send = json.dumps(wrapped_message).encode(
738
+ "utf-8"
739
+ )
740
+ else:
741
+ message_to_send = wrapped_message
426
742
 
427
- @functools.wraps(original_create_session)
428
- async def instrumented_create_session(self, config, *args, **kwargs):
429
- if _global_tracer:
430
- with _global_tracer.start_as_current_span(
431
- "slim.create_session"
432
- ) as span:
433
- session_info = await original_create_session(
434
- self, config, *args, **kwargs
435
- )
743
+ args_list[message_idx] = message_to_send
744
+ args = tuple(args_list)
436
745
 
437
- # Add session attributes to span
438
- if hasattr(session_info, "id"):
439
- span.set_attribute("slim.session.id", str(session_info.id))
746
+ return await original_method(self, *args, **kwargs)
440
747
 
441
- return session_info
442
- else:
443
- return await original_create_session(self, config, *args, **kwargs)
748
+ setattr(session_class, method_name, instrumented_session_publish)
444
749
 
445
- slim_bindings.Slim.create_session = instrumented_create_session
750
+ def _instrument_session_method_if_exists(self, slim_bindings, method_name):
751
+ """Helper to instrument a session method if it exists"""
752
+
753
+ # Look for session classes that might have this method
754
+ for attr_name in dir(slim_bindings):
755
+ attr = getattr(slim_bindings, attr_name)
756
+ if hasattr(attr, method_name):
757
+ original_method = getattr(attr, method_name)
758
+
759
+ if callable(original_method):
760
+ instrumented_method = self._create_session_method_wrapper(
761
+ method_name, original_method
762
+ )
763
+ setattr(attr, method_name, instrumented_method)
764
+
765
+ def _create_session_method_wrapper(self, method_name, original_method):
766
+ """Create an instrumented wrapper for session methods"""
767
+
768
+ @functools.wraps(original_method)
769
+ async def instrumented_session_method(self, *args, **kwargs):
770
+ if _global_tracer:
771
+ with _global_tracer.start_as_current_span(f"session.{method_name}"):
772
+ traceparent = get_current_traceparent()
773
+
774
+ # Handle message wrapping for publish methods
775
+ if method_name in ["publish", "publish_to"] and args:
776
+ # Thread-safe access to kv_store
777
+ session_id = None
778
+ if traceparent:
779
+ with _kv_lock:
780
+ session_id = kv_store.get(f"execution.{traceparent}")
781
+ if session_id:
782
+ kv_store.set(f"execution.{traceparent}", session_id)
783
+
784
+ headers = {
785
+ "session_id": session_id if session_id else None,
786
+ "traceparent": traceparent,
787
+ "slim_session_id": str(self.id)
788
+ if hasattr(self, "id")
789
+ else None,
790
+ }
791
+
792
+ # Set baggage context
793
+ if traceparent and session_id:
794
+ baggage.set_baggage(f"execution.{traceparent}", session_id)
795
+
796
+ # Wrap the message (first argument for publish, second for publish_to)
797
+ message_idx = 1 if method_name == "publish_to" else 0
798
+ if len(args) > message_idx:
799
+ args_list = list(args)
800
+ message = args_list[message_idx]
801
+ wrapped_message = SLIMInstrumentor._wrap_message_with_headers(
802
+ None,
803
+ message,
804
+ headers, # Pass None for self since it's a static method
805
+ )
806
+
807
+ # Convert wrapped message back to bytes if needed
808
+ if isinstance(wrapped_message, dict):
809
+ message_to_send = json.dumps(wrapped_message).encode(
810
+ "utf-8"
811
+ )
812
+ else:
813
+ message_to_send = wrapped_message
814
+
815
+ args_list[message_idx] = message_to_send
816
+ args = tuple(args_list)
446
817
 
818
+ return await original_method(self, *args, **kwargs)
819
+ else:
820
+ # Handle message wrapping even without tracing
821
+ if method_name in ["publish", "publish_to"] and args:
822
+ traceparent = get_current_traceparent()
823
+ session_id = None
824
+ if traceparent:
825
+ with _kv_lock:
826
+ session_id = kv_store.get(f"execution.{traceparent}")
827
+
828
+ if traceparent or session_id:
829
+ headers = {
830
+ "session_id": session_id if session_id else None,
831
+ "traceparent": traceparent,
832
+ "slim_session_id": str(self.id)
833
+ if hasattr(self, "id")
834
+ else None,
835
+ }
836
+
837
+ # Wrap the message
838
+ message_idx = 1 if method_name == "publish_to" else 0
839
+ if len(args) > message_idx:
840
+ args_list = list(args)
841
+ message = args_list[message_idx]
842
+ wrapped_message = (
843
+ SLIMInstrumentor._wrap_message_with_headers(
844
+ None, message, headers
845
+ )
846
+ )
847
+
848
+ if isinstance(wrapped_message, dict):
849
+ message_to_send = json.dumps(wrapped_message).encode(
850
+ "utf-8"
851
+ )
852
+ else:
853
+ message_to_send = wrapped_message
854
+
855
+ args_list[message_idx] = message_to_send
856
+ args = tuple(args_list)
857
+
858
+ return await original_method(self, *args, **kwargs)
859
+
860
+ return instrumented_session_method
861
+
862
+ @staticmethod
447
863
  def _wrap_message_with_headers(self, message, headers):
448
864
  """Helper method to wrap messages with headers consistently"""
449
865
  if isinstance(message, bytes):
@@ -506,12 +922,13 @@ class SLIMInstrumentor(BaseInstrumentor):
506
922
  methods_to_restore = [
507
923
  "publish",
508
924
  "publish_to",
509
- "request_reply",
925
+ "request_reply", # v0.4.0-v0.5.x only
510
926
  "receive",
511
927
  "connect",
512
928
  "create_session",
513
929
  "invite",
514
930
  "set_route",
931
+ "listen_for_session", # v0.6.0+
515
932
  ]
516
933
 
517
934
  for method_name in methods_to_restore:
@@ -521,3 +938,21 @@ class SLIMInstrumentor(BaseInstrumentor):
521
938
  setattr(
522
939
  slim_bindings.Slim, method_name, original_method.__wrapped__
523
940
  )
941
+
942
+ # Also try to restore session-level methods (v0.6.0+)
943
+ # This is best-effort since session classes may vary
944
+ session_methods_to_restore = [
945
+ "publish",
946
+ "publish_to",
947
+ "get_message",
948
+ "invite",
949
+ "remove",
950
+ ]
951
+
952
+ for attr_name in dir(slim_bindings):
953
+ attr = getattr(slim_bindings, attr_name)
954
+ for method_name in session_methods_to_restore:
955
+ if hasattr(attr, method_name):
956
+ original_method = getattr(attr, method_name)
957
+ if hasattr(original_method, "__wrapped__"):
958
+ setattr(attr, method_name, original_method.__wrapped__)
@@ -1,20 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ioa-observe-sdk
3
- Version: 1.0.20
3
+ Version: 1.0.22
4
4
  Summary: IOA Observability SDK
5
+ License-Expression: Apache-2.0
5
6
  Requires-Python: >=3.10
6
7
  Description-Content-Type: text/markdown
7
8
  License-File: LICENSE.md
8
9
  Requires-Dist: colorama==0.4.6
9
10
  Requires-Dist: requests>=2.32.3
10
- Requires-Dist: opentelemetry-api==1.33.1
11
+ Requires-Dist: opentelemetry-api==1.37.0
11
12
  Requires-Dist: opentelemetry-distro
12
- Requires-Dist: opentelemetry-exporter-otlp==1.33.1
13
- Requires-Dist: opentelemetry-exporter-otlp-proto-common==1.33.1
14
- Requires-Dist: opentelemetry-exporter-otlp-proto-grpc==1.33.1
15
- Requires-Dist: opentelemetry-exporter-otlp-proto-http==1.33.1
13
+ Requires-Dist: opentelemetry-exporter-otlp==1.37.0
14
+ Requires-Dist: opentelemetry-exporter-otlp-proto-common==1.37.0
15
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc==1.37.0
16
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http==1.37.0
16
17
  Requires-Dist: opentelemetry-instrumentation
17
- Requires-Dist: opentelemetry-instrumentation-logging==0.54b1
18
+ Requires-Dist: opentelemetry-instrumentation-logging==0.58b0
18
19
  Requires-Dist: opentelemetry-instrumentation-openai==0.43.1
19
20
  Requires-Dist: opentelemetry-instrumentation-llamaindex==0.43.1
20
21
  Requires-Dist: opentelemetry-instrumentation-ollama==0.43.1
@@ -26,18 +27,18 @@ Requires-Dist: opentelemetry-instrumentation-crewai==0.43.1
26
27
  Requires-Dist: opentelemetry-instrumentation-google-generativeai==0.43.1
27
28
  Requires-Dist: opentelemetry-instrumentation-groq==0.43.1
28
29
  Requires-Dist: opentelemetry-instrumentation-mistralai==0.43.1
29
- Requires-Dist: opentelemetry-instrumentation-requests==0.54b1
30
+ Requires-Dist: opentelemetry-instrumentation-requests==0.58b0
30
31
  Requires-Dist: opentelemetry-instrumentation-sagemaker==0.43.1
31
- Requires-Dist: opentelemetry-instrumentation-threading==0.54b1
32
+ Requires-Dist: opentelemetry-instrumentation-threading==0.58b0
32
33
  Requires-Dist: opentelemetry-instrumentation-together==0.43.1
33
34
  Requires-Dist: opentelemetry-instrumentation-transformers==0.43.1
34
- Requires-Dist: opentelemetry-instrumentation-urllib3==0.54b1
35
+ Requires-Dist: opentelemetry-instrumentation-urllib3==0.58b0
35
36
  Requires-Dist: opentelemetry-instrumentation-vertexai==0.43.1
36
- Requires-Dist: opentelemetry-proto==1.33.1
37
- Requires-Dist: opentelemetry-sdk==1.33.1
38
- Requires-Dist: opentelemetry-semantic-conventions==0.54b1
37
+ Requires-Dist: opentelemetry-proto==1.37.0
38
+ Requires-Dist: opentelemetry-sdk==1.37.0
39
+ Requires-Dist: opentelemetry-semantic-conventions==0.58b0
39
40
  Requires-Dist: opentelemetry-semantic-conventions-ai>=0.4.11
40
- Requires-Dist: opentelemetry-util-http==0.54b1
41
+ Requires-Dist: opentelemetry-util-http==0.58b0
41
42
  Requires-Dist: langgraph>=0.3.2
42
43
  Requires-Dist: langchain>=0.3.19
43
44
  Requires-Dist: langchain-openai>=0.3.8
@@ -16,7 +16,8 @@ ioa_observe/sdk/decorators/util.py,sha256=IebvH9gwZN1en3LblYJUh4bAV2STl6xmp8WpZz
16
16
  ioa_observe/sdk/instrumentations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  ioa_observe/sdk/instrumentations/a2a.py,sha256=ZpqvPl4u-yheQzSdBfxnZhWFZ8ntbKni_uaW3IDyjqw,6309
18
18
  ioa_observe/sdk/instrumentations/mcp.py,sha256=vRM3ofnn7AMmry2RrfyZnZVPEutLWiDMghx2TSnm0Wk,18569
19
- ioa_observe/sdk/instrumentations/slim.py,sha256=WIe0E2y6eg5heQ1EA6l9BUDniYpYIjWQ24Pcv_8ZfPk,23147
19
+ ioa_observe/sdk/instrumentations/nats.py,sha256=UOp2AJlm1JkYkwF3xzU_izzohQVQkByjL-AX4n_JRfo,14476
20
+ ioa_observe/sdk/instrumentations/slim.py,sha256=r_vIuYUo-IhSRuYWymqfS26swDZ8_CFDH6p_QbduLKU,43757
20
21
  ioa_observe/sdk/logging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
22
  ioa_observe/sdk/logging/logging.py,sha256=HZxW9s8Due7jgiNkdI38cIjv5rC9D-Flta3RQMOnpow,2891
22
23
  ioa_observe/sdk/metrics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -41,8 +42,8 @@ ioa_observe/sdk/utils/const.py,sha256=d67dUTAH9UpWvUV9GLBUqn1Sc2knJ55dy-e6YoLrvS
41
42
  ioa_observe/sdk/utils/in_memory_span_exporter.py,sha256=H_4TRaThMO1H6vUQ0OpQvzJk_fZH0OOsRAM1iZQXsR8,2112
42
43
  ioa_observe/sdk/utils/json_encoder.py,sha256=g4NQ0tTqgWssY6I1D7r4zo0G6PiUo61jhofTAw5-jno,639
43
44
  ioa_observe/sdk/utils/package_check.py,sha256=1d1MjxhwoEZIx9dumirT2pRsEWgn-m-SI4npDeEalew,576
44
- ioa_observe_sdk-1.0.20.dist-info/licenses/LICENSE.md,sha256=55VjUfgjWOS4vv3Cf55gfq-RxjPgRIO2vlgYPUuC5lA,11362
45
- ioa_observe_sdk-1.0.20.dist-info/METADATA,sha256=rq00vlT-d5xj5wwvTp0j8LeERhmSX1vC90mDkOi9tKI,7027
46
- ioa_observe_sdk-1.0.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
47
- ioa_observe_sdk-1.0.20.dist-info/top_level.txt,sha256=Yt-6Y1olZEDqCs2REeqI30WjYx0pLGQSVqzYmDd67N8,12
48
- ioa_observe_sdk-1.0.20.dist-info/RECORD,,
45
+ ioa_observe_sdk-1.0.22.dist-info/licenses/LICENSE.md,sha256=55VjUfgjWOS4vv3Cf55gfq-RxjPgRIO2vlgYPUuC5lA,11362
46
+ ioa_observe_sdk-1.0.22.dist-info/METADATA,sha256=Uces4XbDjeIZ2okZImfwPXNHUtyoO7od75dHthOs56Q,7058
47
+ ioa_observe_sdk-1.0.22.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
48
+ ioa_observe_sdk-1.0.22.dist-info/top_level.txt,sha256=Yt-6Y1olZEDqCs2REeqI30WjYx0pLGQSVqzYmDd67N8,12
49
+ ioa_observe_sdk-1.0.22.dist-info/RECORD,,