datajet-compass 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.
File without changes
@@ -0,0 +1,11 @@
1
+ import os
2
+ from dotenv import load_dotenv, find_dotenv
3
+
4
+
5
+ # find_dotenv() walks UP the directory tree from cwd until it finds a .env file.
6
+ # This works regardless of where the annotators library is installed or imported from.
7
+ # usecwd=True ensures the search starts from the caller's working directory, not this file's location.
8
+ load_dotenv(find_dotenv(usecwd=True), override=False)
9
+
10
+ LOG_PATH = os.getenv("LOG_PATH", None)
11
+ ENABLE_ONLINE_COMPASS_EVAL = True if os.getenv("ENABLE_ONLINE_COMPASS_EVAL", "false").lower() in ["true", "yes", "1"] else False
@@ -0,0 +1,3 @@
1
+ import contextvars
2
+
3
+ _state_ctx = contextvars.ContextVar("_parent_state")
@@ -0,0 +1,50 @@
1
+ import asyncio
2
+ import functools
3
+
4
+ from .config import ENABLE_ONLINE_COMPASS_EVAL
5
+ from .trace import Trace
6
+
7
+
8
+ def _make_decorator(call_type) :
9
+ def decorator(func):
10
+
11
+ @functools.wraps(func)
12
+ def _sync_wrapper(*args, **kwargs):
13
+ if not ENABLE_ONLINE_COMPASS_EVAL:
14
+ return func(*args, **kwargs)
15
+
16
+ with Trace(call_type=call_type) as trace:
17
+ trace.start_trace(function_name=func.__name__, request={"args": args, "kwargs": kwargs})
18
+
19
+ try:
20
+ result = func(*args, **kwargs)
21
+ return trace.consolidate_trace(output=result, status="SUCCESS")
22
+ except Exception as e:
23
+ return trace.consolidate_trace(status="FAILED", error=e)
24
+
25
+ @functools.wraps(func)
26
+ async def _async_wrapper(*args, **kwargs):
27
+ if not ENABLE_ONLINE_COMPASS_EVAL:
28
+ return await func(*args, **kwargs)
29
+
30
+ with Trace(call_type=call_type) as trace:
31
+ trace.start_trace(function_name=func.__name__, request={"args": args, "kwargs": kwargs})
32
+
33
+ try:
34
+ result = await func(*args, **kwargs)
35
+ return trace.consolidate_trace(output=result, status="SUCCESS")
36
+ except Exception as e:
37
+ return trace.consolidate_trace(status="FAILED", error=e)
38
+
39
+ return _async_wrapper if asyncio.iscoroutinefunction(func) else _sync_wrapper
40
+
41
+ return decorator
42
+
43
+
44
+ # Syntactic Sugar wrappers matching standard trace requirements
45
+ observe = _make_decorator("function")
46
+
47
+ observe_agent = _make_decorator("agent")
48
+
49
+ observe_tool = _make_decorator("tool")
50
+
@@ -0,0 +1,76 @@
1
+ import multiprocessing
2
+ from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
3
+ import contextvars
4
+
5
+ from .context import _state_ctx
6
+ from .log_entry import LogEntry
7
+
8
+
9
+ class ContextThreadPoolExecutor(ThreadPoolExecutor):
10
+ """Thread pool that automatically propagates ContextVar state to worker threads."""
11
+
12
+ def submit(self, fn, *args, **kwargs):
13
+ ctx = contextvars.copy_context()
14
+ return super().submit(ctx.run, fn, *args, **kwargs)
15
+
16
+
17
+ class ContextProcessPoolExecutor(ProcessPoolExecutor):
18
+
19
+ def __init__(self, *args, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+ self._manager = multiprocessing.Manager()
22
+ self._child_states = self._manager.list() # each slot holds ONE CallEntry dict
23
+ self._invocation_counter = 0
24
+
25
+ try:
26
+ self._parent_state = _state_ctx.get()
27
+ self._serialized_parent = self._parent_state.to_object_dict()
28
+ except LookupError:
29
+ self._parent_state = None
30
+ self._serialized_parent = None
31
+
32
+ def submit(self, fn, *args, **kwargs):
33
+ self._invocation_counter += 1
34
+ invocation_order = self._invocation_counter
35
+ self._child_states.append({})
36
+
37
+ return super().submit(
38
+ _run_with_context,
39
+ fn,
40
+ self._serialized_parent,
41
+ self._child_states,
42
+ invocation_order,
43
+ *args,
44
+ **kwargs
45
+ )
46
+
47
+ def __exit__(self, *args):
48
+ result = super().__exit__(*args)
49
+
50
+ # All processes finished — each slot in _child_states is ONE LogEntry dict
51
+ if self._parent_state is not None:
52
+ for call_dict in self._child_states:
53
+ if call_dict:
54
+ entry = LogEntry.from_dict(call_dict)
55
+ self._parent_state.functionCalls.append(entry)
56
+
57
+ self._manager.shutdown()
58
+ return result
59
+
60
+
61
+ def _run_with_context(fn, serialized_state, child_states_list, invocation_order, *args, **kwargs):
62
+ """Runs inside each worker process."""
63
+ if serialized_state:
64
+ state = LogEntry.from_dict(serialized_state)
65
+ _state_ctx.set(state)
66
+
67
+ result = fn(*args, **kwargs)
68
+
69
+ if serialized_state:
70
+ # Always exactly 1 top-level child — store it directly, no loop needed
71
+ child_call = state.functionCalls[0]
72
+ child_call.order = invocation_order
73
+ child_states_list[invocation_order - 1] = child_call.to_object_dict()
74
+
75
+ return result
76
+
@@ -0,0 +1,37 @@
1
+ import json
2
+ import os
3
+ from typing import Dict, Any
4
+
5
+
6
+ class JSONLWriter:
7
+ """
8
+ Writes annotation records to a JSONL file.
9
+ Each execution record is written as ONE JSON object per line.
10
+ """
11
+
12
+ def __init__(self, file_path: str, indent: int = 2):
13
+ """
14
+ :param file_path: Path to the .jsonl file
15
+ :param indent: JSON indentation (default = 2 for readable output)
16
+ """
17
+ self.file_path = file_path
18
+ self.indent = indent
19
+
20
+ def _serialize(self, obj: Any) -> Any:
21
+ # Any object with __dict__ (custom classes)
22
+ if hasattr(obj, "__dict__"):
23
+ return obj.__dict__
24
+ # Fallback — convert to string
25
+ return str(obj)
26
+
27
+ def write(self, record: Dict[str, Any]) -> None:
28
+ """
29
+ Write a single execution record to the JSONL file.
30
+ """
31
+
32
+ os.makedirs(os.path.dirname(self.file_path), exist_ok=True)
33
+ with open(self.file_path, "a", encoding="utf-8") as f:
34
+ f.write(
35
+ json.dumps(record, ensure_ascii=False, indent=self.indent, default=self._serialize)
36
+ + "\n"
37
+ )
@@ -0,0 +1,115 @@
1
+ from threading import Lock
2
+ from datetime import datetime
3
+ from typing import Dict, Any
4
+
5
+ class LogEntry:
6
+ date_fmt = "%Y-%m-%d %H:%M:%S"
7
+
8
+ def __init__(self, testcase_id=None, run_id=None, request=None, type=None, function_name=None, url=None, order=1):
9
+ self._lock = Lock()
10
+ self.call_counter = 1
11
+
12
+ self.testcase_id = testcase_id
13
+ self.run_id = run_id
14
+ self.name = function_name
15
+ self.type = type
16
+ self.target = url
17
+ self.order = order
18
+ self.input = request
19
+ self.output = None
20
+ self.status = "PROCESSING"
21
+ self.error = None
22
+ self.functionCalls = []
23
+ self.start_time = datetime.now().strftime(LogEntry.date_fmt)
24
+ self.end_time = None
25
+
26
+ def get_count(self):
27
+ with self._lock:
28
+ self.call_counter += 1
29
+ self.functionCalls.append({})
30
+
31
+ return self.call_counter - 1
32
+
33
+ def set_response(self, output=None, status=None, error=None):
34
+ self.end_time = datetime.now().strftime(LogEntry.date_fmt)
35
+ self.output = output
36
+ self.status = status
37
+ self.error = error
38
+
39
+ def to_dict(self):
40
+
41
+ start = datetime.strptime(self.start_time, LogEntry.date_fmt)
42
+ end = datetime.strptime(self.end_time, LogEntry.date_fmt)
43
+ duration = end - start
44
+
45
+ return_dict = {
46
+ "testcase_id": self.testcase_id,
47
+ "run_id": self.run_id,
48
+ "name": self.name,
49
+ "order": self.order,
50
+ "execution": {
51
+ "type": self.type,
52
+ "target": self.target,
53
+ "status": self.status,
54
+ "error": self.error,
55
+ "execution_time": f"{duration.total_seconds()} s"
56
+ },
57
+ "input": str(self.input),
58
+ "output": str(self.output),
59
+ "functionCall": [call.to_dict() for call in self.functionCalls if call.type == "function"],
60
+ "agentCall": [call.to_dict() for call in self.functionCalls if call.type == "agent"],
61
+ "toolCall": [call.to_dict() for call in self.functionCalls if call.type == "tool"]
62
+ }
63
+
64
+ keys_to_remove = [
65
+ ('testcase_id', self.testcase_id),
66
+ ('run_id', self.run_id),
67
+ ('order', self.order),
68
+ ('target', self.target)
69
+ ]
70
+ for key, value in keys_to_remove:
71
+ if value is None:
72
+ return_dict.pop(key, None)
73
+
74
+ return {k: v for k, v in return_dict.items() if v != []}
75
+
76
+ def to_object_dict(self):
77
+ return {
78
+ "testcase_id": self.testcase_id,
79
+ "run_id": self.run_id,
80
+ "type": self.type,
81
+ "target": self.target,
82
+ "name": self.name,
83
+ "order": self.order,
84
+ "status": self.status,
85
+ "error": self.error,
86
+ "start_time": self.start_time,
87
+ "end_time": self.end_time,
88
+ "input": str(self.input),
89
+ "output": str(self.output),
90
+ "functionCalls": [call.to_object_dict() for call in self.functionCalls]
91
+ }
92
+
93
+ @classmethod
94
+ def from_dict(cls, data: Dict[str, Any]) -> 'LogEntry':
95
+ data = dict(data)
96
+ calls_data = data.pop("functionCalls", [])
97
+
98
+ # to_object_dict fields -> __init__ fields
99
+ obj = cls(
100
+ testcase_id=data.get("testcase_id", None),
101
+ run_id=data.get("run_id", None),
102
+ request=data.get("request", None),
103
+ type=data.get("type", None),
104
+ function_name=data.get("name", None),
105
+ url=data.get("target", None),
106
+ order=data.get("order", 1)
107
+ )
108
+ # restore live state fields
109
+ obj.status = data["status"]
110
+ obj.error = data["error"]
111
+ obj.output = data["output"]
112
+ obj.start_time = data["start_time"]
113
+ obj.end_time = data["end_time"]
114
+ obj.functionCalls = [LogEntry.from_dict(c) for c in calls_data]
115
+ return obj
@@ -0,0 +1,69 @@
1
+ import threading
2
+ from .context import _state_ctx
3
+ from .file_writter import JSONLWriter
4
+ from .log_entry import LogEntry
5
+ from .config import LOG_PATH, ENABLE_ONLINE_COMPASS_EVAL
6
+
7
+
8
+ class Trace:
9
+
10
+ def __init__(self, call_type = "api"):
11
+ self.call_type = call_type
12
+ self.token = None
13
+ self.parent = None
14
+ self.current_state = None
15
+ self._lock = threading.Lock()
16
+
17
+ def start_trace(self, function_name = None, request = None, url = None):
18
+
19
+ if not ENABLE_ONLINE_COMPASS_EVAL:
20
+ return None, None
21
+
22
+ try:
23
+ self.parent = _state_ctx.get()
24
+ except Exception as e:
25
+ self.parent = None
26
+
27
+ with threading.Lock():
28
+ testcase_id = request.get("testcase_id", None) if isinstance(request, dict) else None
29
+ run_id = request.get("run_id", None) if isinstance(request, dict) else None
30
+
31
+ # - 1 is added because a dummy of the current call has been added to the parent's functionCalls list on get_count()
32
+ order_count = self.parent.get_count() if self.parent else None
33
+
34
+ state = LogEntry(testcase_id=testcase_id, run_id=run_id, request=request, type=self.call_type, function_name=function_name, url=url, order=order_count)
35
+
36
+ if self.parent is not None:
37
+ self.parent.functionCalls[order_count - 1] = state
38
+
39
+ self.token = _state_ctx.set(state)
40
+ self.current_state = state
41
+
42
+ return self.token, self.current_state
43
+
44
+ def consolidate_trace(self,output=None, status=None, error=None):
45
+
46
+ if ENABLE_ONLINE_COMPASS_EVAL:
47
+ self.current_state.set_response(output=output, status=status, error=error)
48
+
49
+ if self.parent is None and self.current_state.testcase_id is not None and self.current_state.run_id is not None:
50
+ print(self.current_state.to_dict())
51
+ return {"actual_response": output if error is None else error, "trace": self.current_state.to_dict()}
52
+ elif self.parent is None and self.current_state.testcase_id is None and self.current_state.run_id is None and LOG_PATH is not None:
53
+ with self._lock:
54
+ writer = JSONLWriter(LOG_PATH)
55
+ writer.write(self.current_state.to_dict())
56
+
57
+ return output if error is None else error
58
+
59
+ def __enter__(self):
60
+ return self
61
+
62
+ def __exit__(self, exc_type, exc_val, exc_tb):
63
+ if self.token:
64
+ _state_ctx.reset(self.token)
65
+ self.token = None
66
+ return False
67
+
68
+
69
+
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: datajet-compass
3
+ Version: 0.1.0
4
+ Summary: Observability library for AI agents and tools
5
+ Author-email: Basil K <Basil.Kalathodi@ibsplc.com>, Rose Baiju Malakaran <Rose.Malakaran@ibsplc.com>, Liana S <Liana.Salim@ibsplc.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/imaginebediscovered/datajet-compass
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+
14
+ # DataJet Compass
15
+
16
+ Observability library for AI agents and tools.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install datajet-compass
@@ -0,0 +1,12 @@
1
+ datajet_compass/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ datajet_compass/config.py,sha256=W7CtH0ph-Rcy1CjgSsjv9XwkjlkblJMysE1ftzUkvHw,550
3
+ datajet_compass/context.py,sha256=x6yFXkZINmzFGBhVRX1-X7KvA5F8dLbMXu1JhGFQvlk,76
4
+ datajet_compass/decorators.py,sha256=UJhjsEXNSMl_f7eUR98f4mfm9pNGLlX4S_vtVYAjflU,1820
5
+ datajet_compass/executors.py,sha256=_OAPi8p4AuxedtuOjqxJDPDQwr6-2c0GFs3nqtIyFy8,2547
6
+ datajet_compass/file_writter.py,sha256=6JY5e2dWWzx2OImxgH_OhMnvB97PgpH_-W9mKU_jPNM,1183
7
+ datajet_compass/log_entry.py,sha256=HeNqP5w5EEcnZW0G3K0dOVZ0oXCJmIw3hYOPcQ_UvTQ,4166
8
+ datajet_compass/trace.py,sha256=jrMykjDqOOCjgK8rlQuK6nQGqlBmBQdNjduRQoxmHqE,2740
9
+ datajet_compass-0.1.0.dist-info/METADATA,sha256=DRHA08ahbwInT4fcDrndK2CG9xcFC1JGPPWMkDxou0w,693
10
+ datajet_compass-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ datajet_compass-0.1.0.dist-info/top_level.txt,sha256=qOGTjNmNfjeSXcmqLs4sowDpq-5fDxnIUg5ejdEhO6g,16
12
+ datajet_compass-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ datajet_compass