cortexops 0.4.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (69) hide show
  1. {cortexops-0.4.0 → cortexops-0.5.0}/.gitignore +1 -0
  2. {cortexops-0.4.0 → cortexops-0.5.0}/PKG-INFO +3 -2
  3. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/__init__.py +1 -1
  4. cortexops-0.5.0/cortexops/client.py +150 -0
  5. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/pyproject.toml +1 -1
  6. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/tracer.py +33 -8
  7. {cortexops-0.4.0 → cortexops-0.5.0}/pyproject.toml +3 -2
  8. cortexops-0.5.0/tests/__init__.py +1 -0
  9. cortexops-0.5.0/tests/conftest.py +38 -0
  10. cortexops-0.5.0/tests/helpers.py +51 -0
  11. cortexops-0.5.0/tests/test_agno.py +7 -0
  12. cortexops-0.5.0/tests/test_api_client.py +25 -0
  13. cortexops-0.5.0/tests/test_async.py +18 -0
  14. cortexops-0.5.0/tests/test_auth.py +17 -0
  15. cortexops-0.5.0/tests/test_autogen.py +12 -0
  16. cortexops-0.5.0/tests/test_cli.py +15 -0
  17. cortexops-0.5.0/tests/test_concurrency.py +17 -0
  18. cortexops-0.5.0/tests/test_config.py +26 -0
  19. cortexops-0.5.0/tests/test_cost_tracking.py +10 -0
  20. cortexops-0.5.0/tests/test_crewai.py +12 -0
  21. cortexops-0.5.0/tests/test_datadog.py +8 -0
  22. cortexops-0.5.0/tests/test_edge_cases.py +21 -0
  23. cortexops-0.5.0/tests/test_end_to_end.py +46 -0
  24. cortexops-0.5.0/tests/test_eval_suite.py +20 -0
  25. cortexops-0.5.0/tests/test_evaluation.py +38 -0
  26. cortexops-0.5.0/tests/test_exceptions.py +8 -0
  27. cortexops-0.5.0/tests/test_exporters.py +11 -0
  28. cortexops-0.5.0/tests/test_framework_detection.py +41 -0
  29. cortexops-0.5.0/tests/test_golden_dataset.py +15 -0
  30. cortexops-0.5.0/tests/test_google_adk.py +12 -0
  31. cortexops-0.5.0/tests/test_grafana.py +8 -0
  32. cortexops-0.5.0/tests/test_haystack.py +12 -0
  33. cortexops-0.5.0/tests/test_honeycomb.py +13 -0
  34. cortexops-0.5.0/tests/test_jaeger.py +9 -0
  35. cortexops-0.5.0/tests/test_langgraph.py +13 -0
  36. cortexops-0.5.0/tests/test_llamaindex.py +12 -0
  37. cortexops-0.5.0/tests/test_llm_calls.py +19 -0
  38. cortexops-0.5.0/tests/test_llm_judge.py +20 -0
  39. cortexops-0.5.0/tests/test_logging.py +12 -0
  40. cortexops-0.5.0/tests/test_metrics.py +35 -0
  41. cortexops-0.5.0/tests/test_nodes.py +25 -0
  42. cortexops-0.5.0/tests/test_openai_agents.py +7 -0
  43. cortexops-0.5.0/tests/test_otlp.py +7 -0
  44. cortexops-0.5.0/tests/test_pydantic_ai.py +14 -0
  45. cortexops-0.5.0/tests/test_redaction.py +9 -0
  46. cortexops-0.5.0/tests/test_regressions.py +19 -0
  47. cortexops-0.5.0/tests/test_retry.py +21 -0
  48. cortexops-0.5.0/tests/test_sampling.py +9 -0
  49. cortexops-0.5.0/tests/test_serialization.py +12 -0
  50. cortexops-0.5.0/tests/test_smolagents.py +12 -0
  51. cortexops-0.5.0/tests/test_spans.py +19 -0
  52. cortexops-0.5.0/tests/test_tools.py +23 -0
  53. cortexops-0.5.0/tests/test_tracing.py +54 -0
  54. cortexops-0.4.0/cortexops/client.py +0 -83
  55. cortexops-0.4.0/tests/__init__.py +0 -0
  56. cortexops-0.4.0/tests/conftest.py +0 -8
  57. cortexops-0.4.0/tests/test_cortexops.py +0 -211
  58. cortexops-0.4.0/tests/test_enhancements.py +0 -398
  59. {cortexops-0.4.0 → cortexops-0.5.0}/LICENSE +0 -0
  60. {cortexops-0.4.0 → cortexops-0.5.0}/README.md +0 -0
  61. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/LICENSE +0 -0
  62. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/README.md +0 -0
  63. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/auth.py +0 -0
  64. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/cli.py +0 -0
  65. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/dataset.py +0 -0
  66. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/eval.py +0 -0
  67. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/judge.py +0 -0
  68. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/metrics.py +0 -0
  69. {cortexops-0.4.0 → cortexops-0.5.0}/cortexops/models.py +0 -0
@@ -2,6 +2,7 @@
2
2
  __pycache__/
3
3
  *.py[cod]
4
4
  *.pyo
5
+ .venv-e2e/
5
6
  .venv/
6
7
  venv/
7
8
  .env
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortexops
3
- Version: 0.4.0
4
- Summary: Reliability infrastructure for AI agentsevaluation, observability, and regression testing
3
+ Version: 0.5.0
4
+ Summary: AI agent observability & eval platform trace, monitor, and eval-gate LangGraph, CrewAI, OpenAI Agents, PydanticAI, Agno, AutoGen, Haystack, DSPy, LlamaIndex, Smolagents, and Google ADK
5
5
  Project-URL: Homepage, https://getcortexops.com
6
6
  Project-URL: Repository, https://github.com/ashishodu2023/cortexops
7
7
  Project-URL: Documentation, https://docs.getcortexops.com
@@ -44,6 +44,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
44
44
  Classifier: Topic :: Software Development :: Testing
45
45
  Classifier: Typing :: Typed
46
46
  Requires-Python: >=3.10
47
+ Requires-Dist: httpx>=0.27
47
48
  Requires-Dist: pydantic>=2.0
48
49
  Requires-Dist: pyyaml>=6.0
49
50
  Provides-Extra: all
@@ -34,7 +34,7 @@ from .models import (
34
34
  )
35
35
  from .tracer import CortexTracer
36
36
 
37
- __version__ = "0.4.0"
37
+ __version__ = "0.5.0"
38
38
 
39
39
  __all__ = [
40
40
  "CortexTracer",
@@ -0,0 +1,150 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import urljoin
4
+
5
+ from .models import Trace
6
+
7
+
8
+ class CortexClient:
9
+ """HTTP client for the CortexOps backend API.
10
+
11
+ Used by the SDK to push traces and pull eval history.
12
+ Not required for local-only usage.
13
+
14
+ Usage:
15
+ client = CortexClient(api_key="cxo-...", base_url="https://api.cortexops.ai")
16
+ client.push_trace(tracer.last_trace())
17
+ history = client.list_runs(project="payments-agent", limit=10)
18
+ """
19
+
20
+ DEFAULT_BASE_URL = "https://api.getcortexops.com"
21
+
22
+ def __init__(
23
+ self,
24
+ api_key: str,
25
+ base_url: str | None = None,
26
+ timeout: float = 10.0,
27
+ ) -> None:
28
+ self.api_key = api_key
29
+ self.base_url = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
30
+ self.timeout = timeout
31
+
32
+ def _headers(self) -> dict[str, str]:
33
+ return {
34
+ "X-API-Key": self.api_key,
35
+ "Content-Type": "application/json",
36
+ }
37
+
38
+ def _get(self, path: str, params: dict | None = None) -> dict:
39
+ import httpx
40
+ r = httpx.get(
41
+ urljoin(self.base_url + "/", path.lstrip("/")),
42
+ headers=self._headers(),
43
+ params=params,
44
+ timeout=self.timeout,
45
+ )
46
+ r.raise_for_status()
47
+ return r.json()
48
+
49
+ def _post(self, path: str, data: dict) -> dict:
50
+ import httpx
51
+ r = httpx.post(
52
+ urljoin(self.base_url + "/", path.lstrip("/")),
53
+ headers=self._headers(),
54
+ json=data,
55
+ timeout=self.timeout,
56
+ )
57
+ r.raise_for_status()
58
+ return r.json()
59
+
60
+ def push_trace(self, trace: Trace) -> dict:
61
+ return self._post("/v1/traces", trace.model_dump(mode="json"))
62
+
63
+ def get_trace(self, trace_id: str) -> dict:
64
+ return self._get(f"/v1/traces/{trace_id}")
65
+
66
+ def list_traces(self, project: str, limit: int = 50) -> list[dict]:
67
+ return self._get("/v1/traces", {"project": project, "limit": limit})
68
+
69
+ def push_eval(self, summary, *, dataset: dict | None = None, prompt: dict | None = None) -> dict:
70
+ """Push a completed EvalSuite summary to POST /v1/evals/ingest."""
71
+ if hasattr(summary, "model_dump"):
72
+ data = summary.model_dump(mode="json")
73
+ else:
74
+ data = dict(summary)
75
+ for cr in data.get("case_results", []):
76
+ cr.pop("trace", None)
77
+ if cr.get("failure_kind") and hasattr(cr["failure_kind"], "value"):
78
+ cr["failure_kind"] = cr["failure_kind"].value
79
+ if dataset:
80
+ data["dataset"] = dataset
81
+ if prompt:
82
+ data["prompt"] = prompt
83
+ return self._post("/v1/evals/ingest", data)
84
+
85
+ def project_for_key(self) -> str:
86
+ """Return the project bound to the current API key."""
87
+ return self._get("/v1/traces/quota")["project"]
88
+
89
+ def list_runs(self, project: str, limit: int = 10) -> list[dict]:
90
+ return self._get("/v1/evals", {"project": project, "limit": limit})
91
+
92
+ def run_eval(self, dataset: str, project: str) -> dict:
93
+ """Trigger a server-side eval run (async via Celery)."""
94
+ return self._post("/v1/evals/run", {"dataset": dataset, "project": project})
95
+
96
+ def get_eval(self, run_id: str) -> dict:
97
+ return self._get(f"/v1/evals/{run_id}")
98
+
99
+ def diff(self, run_id_a: str, run_id_b: str) -> dict:
100
+ return self._get("/v1/evals/diff", {"a": run_id_a, "b": run_id_b})
101
+
102
+ def list_datasets(self) -> list[dict]:
103
+ return self._get("/v1/eval/datasets")
104
+
105
+ def get_dataset(self, dataset_id: str) -> dict:
106
+ return self._get(f"/v1/eval/datasets/{dataset_id}")
107
+
108
+ def create_dataset(self, name: str, cases: list[dict], description: str = "") -> dict:
109
+ return self._post("/v1/eval/datasets", {
110
+ "name": name,
111
+ "description": description,
112
+ "cases": cases,
113
+ })
114
+
115
+ def add_case_from_trace(
116
+ self,
117
+ dataset_id: str,
118
+ trace_id: str,
119
+ *,
120
+ case_id: str | None = None,
121
+ ) -> dict:
122
+ """Promote a production trace into a golden dataset case."""
123
+ body: dict = {"trace_id": trace_id}
124
+ if case_id:
125
+ body["case_id"] = case_id
126
+ return self._post(f"/v1/eval/datasets/{dataset_id}/cases/from-trace", body)
127
+
128
+ def commit_prompt(
129
+ self,
130
+ project: str,
131
+ prompt_name: str,
132
+ content: str,
133
+ *,
134
+ model: str = "",
135
+ temperature: float = 0.7,
136
+ commit_message: str = "",
137
+ author: str = "",
138
+ ) -> dict:
139
+ return self._post("/v1/prompts", {
140
+ "project": project,
141
+ "prompt_name": prompt_name,
142
+ "content": content,
143
+ "model": model,
144
+ "temperature": temperature,
145
+ "commit_message": commit_message,
146
+ "author": author,
147
+ })
148
+
149
+ def list_prompt_catalog(self, project: str) -> list[dict]:
150
+ return self._get("/v1/prompts/catalog", {"project": project})
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cortexops"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "AI agent observability & eval platform — trace, monitor, and eval-gate LangGraph, CrewAI, OpenAI Agents, PydanticAI, Agno, AutoGen, Haystack, DSPy, LlamaIndex, Smolagents, and Google ADK"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import random as _random
4
5
  import time
5
6
  import uuid
6
7
  from collections.abc import Callable
@@ -90,7 +91,11 @@ class CortexTracer:
90
91
  self.api_url = _resolve_api_url(api_url)
91
92
  # Environment: arg > env var > "development"
92
93
  self.environment = environment or os.getenv(_ENV_ENV, "development")
94
+ # Validate and store sample rate (0.0 = trace nothing, 1.0 = trace everything)
95
+ if not 0.0 <= sample_rate <= 1.0:
96
+ raise ValueError(f"sample_rate must be between 0.0 and 1.0, got {sample_rate}")
93
97
  self.sample_rate = sample_rate
98
+ self.sample_errors = True # always trace failures regardless of sample_rate
94
99
  self.local_store = local_store
95
100
  self._traces: list[Trace] = []
96
101
  self._current_trace: Trace | None = None
@@ -129,21 +134,21 @@ class CortexTracer:
129
134
  # CrewAI
130
135
  if name == "Crew" and "crewai" in module:
131
136
  return "crewai"
132
- # OpenAI Agents SDK Agent class
133
- if name == "Agent" and "agents" in module:
134
- return "openai_agents"
137
+ # Google ADK (before OpenAI Agents — module path contains "agents")
138
+ if "google" in module and "adk" in module:
139
+ return "google_adk"
135
140
  # PydanticAI — Agent class
136
141
  if name == "Agent" and "pydantic_ai" in module:
137
142
  return "pydantic_ai"
143
+ # OpenAI Agents SDK — top-level `agents` package only
144
+ if name == "Agent" and (module == "agents" or module.startswith("agents.")):
145
+ return "openai_agents"
138
146
  # Agno / Phidata
139
147
  if "agno" in module or "phi" in module:
140
148
  return "agno"
141
149
  # AutoGen — ConversableAgent, AssistantAgent, UserProxyAgent
142
150
  if "autogen" in module and hasattr(agent, "initiate_chat"):
143
151
  return "autogen"
144
- # Google ADK
145
- if "google" in module and "adk" in module:
146
- return "google_adk"
147
152
  # Smolagents (HuggingFace)
148
153
  if "smolagents" in module and hasattr(agent, "run"):
149
154
  return "smolagents"
@@ -617,10 +622,30 @@ class CortexTracer:
617
622
 
618
623
 
619
624
  def _run_traced(self, fn: Callable, input: dict, framework: str) -> Any:
620
- import random
621
- if self.sample_rate < 1.0 and random.random() > self.sample_rate:
625
+ # Head-based sampling: decide whether to trace this run.
626
+ # Errors are always traced (if sample_errors=True) so you never
627
+ # miss a failure even at low sample rates.
628
+ sampled = self.sample_rate >= 1.0 or _random.random() < self.sample_rate
629
+
630
+ if not sampled and not self.sample_errors:
631
+ # Not sampled and not capturing errors — run untraced
622
632
  return fn()
623
633
 
634
+ if not sampled and self.sample_errors:
635
+ # Not sampled, but we still want to catch errors.
636
+ # Run with lightweight error-only capture.
637
+ try:
638
+ return fn()
639
+ except Exception as exc:
640
+ # An error occurred — capture it even though not sampled
641
+ trace = Trace(project=self.project, input=input)
642
+ trace.status = RunStatus.FAILED
643
+ trace.failure_kind = FailureKind.UNKNOWN
644
+ trace.failure_detail = str(exc)
645
+ trace.metadata = {"sampled": False, "captured_reason": "error"}
646
+ self._traces.append(trace)
647
+ raise
648
+
624
649
  trace = Trace(project=self.project, input=input)
625
650
  self._current_trace = trace
626
651
  t0 = time.perf_counter()
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cortexops"
7
- version = "0.4.0"
8
- description = "Reliability infrastructure for AI agentsevaluation, observability, and regression testing"
7
+ version = "0.5.0"
8
+ description = "AI agent observability & eval platform trace, monitor, and eval-gate LangGraph, CrewAI, OpenAI Agents, PydanticAI, Agno, AutoGen, Haystack, DSPy, LlamaIndex, Smolagents, and Google ADK"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
11
11
  authors = [
@@ -33,6 +33,7 @@ requires-python = ">=3.10"
33
33
  dependencies = [
34
34
  "pydantic>=2.0",
35
35
  "pyyaml>=6.0",
36
+ "httpx>=0.27",
36
37
  ]
37
38
 
38
39
  [project.optional-dependencies]
@@ -0,0 +1 @@
1
+ # Test package for CortexOps SDK integration tests.
@@ -0,0 +1,38 @@
1
+ """Shared fixtures for CortexOps SDK integration tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from tests.helpers import make_framework_mock, make_trace
8
+
9
+ __all__ = ["make_framework_mock", "make_trace"]
10
+
11
+
12
+ @pytest.fixture
13
+ def project() -> str:
14
+ return "payments-agent"
15
+
16
+
17
+ @pytest.fixture
18
+ def tracer(project):
19
+ from cortexops import CortexTracer
20
+
21
+ return CortexTracer(project=project, sample_rate=1.0, local_store=True)
22
+
23
+
24
+ @pytest.fixture
25
+ def echo_agent():
26
+ def _agent(inp: dict) -> dict:
27
+ text = inp.get("input", inp.get("prompt", ""))
28
+ return {"output": f"Processed: {text}"}
29
+
30
+ return _agent
31
+
32
+
33
+ @pytest.fixture
34
+ def failing_agent():
35
+ def _agent(inp: dict) -> dict:
36
+ raise RuntimeError("agent exploded")
37
+
38
+ return _agent
@@ -0,0 +1,51 @@
1
+ """Shared helpers for CortexOps SDK integration tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cortexops.models import (
6
+ RunStatus,
7
+ ToolCall,
8
+ ToolCallStatus,
9
+ Trace,
10
+ TraceNode,
11
+ )
12
+
13
+
14
+ def make_trace(
15
+ output: dict | None = None,
16
+ tool_calls: list[str] | None = None,
17
+ latency_ms: float = 100.0,
18
+ *,
19
+ project: str = "test",
20
+ llm_prompt: str | None = None,
21
+ llm_response: str | None = None,
22
+ parent_nodes: list[TraceNode] | None = None,
23
+ ) -> Trace:
24
+ tcs = [ToolCall(name=n, status=ToolCallStatus.SUCCESS) for n in (tool_calls or [])]
25
+ nodes = parent_nodes or [
26
+ TraceNode(
27
+ node_id="n1",
28
+ node_name="agent",
29
+ output=output or {},
30
+ tool_calls=tcs,
31
+ latency_ms=latency_ms,
32
+ llm_prompt=llm_prompt,
33
+ llm_response=llm_response,
34
+ )
35
+ ]
36
+ return Trace(
37
+ project=project,
38
+ total_latency_ms=latency_ms,
39
+ output=output or {},
40
+ nodes=nodes,
41
+ status=RunStatus.COMPLETED,
42
+ )
43
+
44
+
45
+ def make_framework_mock(class_name: str, module: str, **attrs):
46
+ """Build a lightweight stand-in with the given class name/module for detection."""
47
+ cls = type(class_name, (), {"__module__": module})
48
+ obj = cls()
49
+ for key, value in attrs.items():
50
+ setattr(obj, key, value)
51
+ return obj
@@ -0,0 +1,7 @@
1
+ from cortexops.tracer import CortexTracer
2
+ from tests.helpers import make_framework_mock
3
+
4
+
5
+ def test_agno_detected():
6
+ mock = make_framework_mock("Agent", "agno.agent.agent")
7
+ assert CortexTracer._detect_framework(mock) == "agno"
@@ -0,0 +1,25 @@
1
+ """CortexClient HTTPS upload path (mocked)."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from cortexops.client import CortexClient
6
+ from tests.helpers import make_trace
7
+
8
+
9
+ def test_upload_trace():
10
+ client = CortexClient(api_key="cxo-test", base_url="https://api.getcortexops.com")
11
+ trace = make_trace({"output": "ok"}, project="payments-agent")
12
+ with patch.object(client, "_post", return_value={"id": trace.trace_id}) as post:
13
+ resp = client.push_trace(trace)
14
+ assert resp["id"] == trace.trace_id
15
+ post.assert_called_once()
16
+ args, kwargs = post.call_args
17
+ assert args[0] == "/v1/traces"
18
+
19
+
20
+ def test_list_traces():
21
+ client = CortexClient(api_key="cxo-test", base_url="https://api.getcortexops.com")
22
+ with patch.object(client, "_get", return_value=[{"trace_id": "abc"}]) as get:
23
+ rows = client.list_traces("payments-agent", limit=10)
24
+ assert rows[0]["trace_id"] == "abc"
25
+ get.assert_called_once()
@@ -0,0 +1,18 @@
1
+ """Async-friendly wrappers exist for supported frameworks."""
2
+
3
+ import asyncio
4
+ from unittest.mock import MagicMock
5
+
6
+ from cortexops import CortexTracer
7
+ from tests.helpers import make_framework_mock
8
+
9
+
10
+ def test_langgraph_ainvoke_exists():
11
+ tracer = CortexTracer(project="p", sample_rate=1.0)
12
+ mock = make_framework_mock("CompiledStateGraph", "langgraph.graph.graph")
13
+ mock.invoke = MagicMock(return_value={"ok": True})
14
+ wrapped = tracer.wrap(mock)
15
+ assert hasattr(wrapped, "ainvoke")
16
+
17
+ result = asyncio.run(wrapped.ainvoke({"messages": []}))
18
+ assert result["ok"] is True
@@ -0,0 +1,17 @@
1
+ """Credential save/load/clear."""
2
+
3
+ from cortexops.auth import save_credentials, load_credentials, clear_credentials
4
+
5
+
6
+ def test_save_load_clear(tmp_path, monkeypatch):
7
+ cred_dir = tmp_path / ".cortexops"
8
+ cred_file = cred_dir / "credentials"
9
+ monkeypatch.setattr("cortexops.auth._CREDENTIALS_DIR", cred_dir)
10
+ monkeypatch.setattr("cortexops.auth._CREDENTIALS_FILE", cred_file)
11
+
12
+ save_credentials("cxo-testkey", "payments-agent")
13
+ creds = load_credentials()
14
+ assert creds["api_key"] == "cxo-testkey"
15
+ assert creds["project"] == "payments-agent"
16
+ clear_credentials()
17
+ assert load_credentials() is None
@@ -0,0 +1,12 @@
1
+ from unittest.mock import MagicMock
2
+ from cortexops.tracer import CortexTracer
3
+ from tests.helpers import make_framework_mock
4
+
5
+
6
+ def test_autogen_detected():
7
+ mock = make_framework_mock(
8
+ "AssistantAgent",
9
+ "autogen.agentchat.assistant_agent",
10
+ initiate_chat=MagicMock(),
11
+ )
12
+ assert CortexTracer._detect_framework(mock) == "autogen"
@@ -0,0 +1,15 @@
1
+ """CLI entrypoints."""
2
+
3
+ from cortexops.cli import cmd_version
4
+ from argparse import Namespace
5
+
6
+
7
+ def test_cli_eval():
8
+ """Version command is wired and returns success."""
9
+ assert cmd_version(Namespace()) == 0
10
+
11
+
12
+ def test_cli_main_importable():
13
+ from cortexops.cli import main
14
+
15
+ assert callable(main)
@@ -0,0 +1,17 @@
1
+ """Tracer is usable across sequential calls (thread-local MVP)."""
2
+
3
+ from cortexops import CortexTracer
4
+
5
+
6
+ def test_sequential_traces_isolated():
7
+ tracer = CortexTracer(project="p", sample_rate=1.0)
8
+
9
+ def agent(inp: dict) -> dict:
10
+ return {"output": inp["input"]}
11
+
12
+ wrapped = tracer.wrap(agent)
13
+ wrapped({"input": "one"})
14
+ wrapped({"input": "two"})
15
+ traces = tracer.traces()
16
+ assert len(traces) == 2
17
+ assert traces[0].trace_id != traces[1].trace_id
@@ -0,0 +1,26 @@
1
+ """Tracer configuration."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from cortexops import CortexTracer
8
+
9
+
10
+ def test_sample_rate_bounds():
11
+ with pytest.raises(ValueError):
12
+ CortexTracer(project="p", sample_rate=1.5)
13
+
14
+
15
+ def test_project_from_env(monkeypatch):
16
+ monkeypatch.setenv("CORTEXOPS_PROJECT", "env-project")
17
+ t = CortexTracer(sample_rate=1.0)
18
+ assert t.project == "env-project"
19
+
20
+
21
+ def test_local_only_without_key(monkeypatch, tmp_path):
22
+ monkeypatch.delenv("CORTEXOPS_API_KEY", raising=False)
23
+ monkeypatch.setattr("cortexops.tracer._CREDENTIALS_FILE", tmp_path / "missing-credentials")
24
+ t = CortexTracer(project="local", api_key=None)
25
+ assert t.is_hosted is False
26
+ assert isinstance(tmp_path, Path)
@@ -0,0 +1,10 @@
1
+ """Cost-related fields on traces (latency proxy for MVP)."""
2
+
3
+ from tests.helpers import make_trace
4
+
5
+
6
+ def test_cost_proxy_latency():
7
+ t = make_trace({"output": "ok"}, latency_ms=250)
8
+ # MVP: cost analytics uses latency + tool count as proxies
9
+ cost_signal = {"latency_ms": t.total_latency_ms, "tool_calls": t.total_tool_calls()}
10
+ assert cost_signal["latency_ms"] == 250
@@ -0,0 +1,12 @@
1
+ from unittest.mock import MagicMock
2
+ from cortexops import CortexTracer, RunStatus
3
+ from tests.helpers import make_framework_mock
4
+
5
+
6
+ def test_crewai_wrap_traces():
7
+ tracer = CortexTracer(project="test", sample_rate=1.0)
8
+ mock = make_framework_mock("Crew", "crewai.crew")
9
+ mock.kickoff = MagicMock(return_value="done")
10
+ wrapped = tracer.wrap(mock)
11
+ assert wrapped.kickoff(inputs={"ticket": "1"}) == "done"
12
+ assert tracer.last_trace().status == RunStatus.COMPLETED
@@ -0,0 +1,8 @@
1
+ from tests.helpers import make_trace
2
+
3
+
4
+ def test_datadog_span_tags():
5
+ t = make_trace({"output": "ok"}, latency_ms=10)
6
+ tags = [f"project:{t.project}", f"status:{t.status.value}"]
7
+ assert "project:test" in tags
8
+ assert "status:completed" in tags
@@ -0,0 +1,21 @@
1
+ """Edge cases."""
2
+
3
+ from cortexops import CortexTracer, RunStatus
4
+
5
+
6
+ def test_empty_input(tracer):
7
+ def agent(inp: dict) -> dict:
8
+ return {"output": "ok"}
9
+
10
+ wrapped = tracer.wrap(agent)
11
+ wrapped({})
12
+ assert tracer.last_trace().status == RunStatus.COMPLETED
13
+
14
+
15
+ def test_non_dict_output(tracer):
16
+ def agent(inp: dict):
17
+ return "plain-string"
18
+
19
+ wrapped = tracer.wrap(agent)
20
+ assert wrapped({"input": "x"}) == "plain-string"
21
+ assert tracer.last_trace() is not None
@@ -0,0 +1,46 @@
1
+ """
2
+ End-to-end MVP path:
3
+
4
+ Agent frameworks → CortexOps SDK (tracer.wrap) → local traces
5
+ → metrics / eval gate → (optional) HTTPS upload via CortexClient
6
+ """
7
+
8
+ from unittest.mock import patch
9
+
10
+ from cortexops import CortexTracer, EvalSuite, CortexClient
11
+ from cortexops.models import EvalCase, EvalDataset
12
+ from tests.helpers import make_trace
13
+
14
+
15
+ def test_sdk_to_cloud_path():
16
+ tracer = CortexTracer(project="payments-agent", sample_rate=1.0)
17
+
18
+ def agent(inp: dict) -> dict:
19
+ return {"output": f"hello {inp.get('input', '')}"}
20
+
21
+ wrapped = tracer.wrap(agent)
22
+ wrapped({"input": "world"})
23
+ trace = tracer.last_trace()
24
+ assert trace.project == "payments-agent"
25
+ assert "hello" in str(trace.output)
26
+
27
+ ds = EvalDataset(
28
+ version=1,
29
+ project="payments-agent",
30
+ cases=[EvalCase(id="c1", input="world", expected_output_contains=["hello"])],
31
+ )
32
+ summary = EvalSuite.run(dataset=ds, agent=agent, verbose=False)
33
+ assert summary.passed == 1
34
+
35
+ client = CortexClient(api_key="cxo-test", base_url="https://api.getcortexops.com")
36
+ with patch.object(client, "push_trace", return_value={"ok": True}) as push:
37
+ client.push_trace(trace)
38
+ push.assert_called_once()
39
+
40
+
41
+ def test_dashboard_surface_fields():
42
+ """Fields the dashboard consumes are present on traces/evals."""
43
+ t = make_trace({"output": "ok"}, tool_calls=["lookup"], latency_ms=33, project="payments-agent")
44
+ payload = t.model_dump(mode="json")
45
+ for key in ("trace_id", "project", "status", "total_latency_ms", "nodes", "failure_kind"):
46
+ assert key in payload
@@ -0,0 +1,20 @@
1
+ """EvalSuite summary rendering."""
2
+
3
+ from cortexops import EvalSuite
4
+ from cortexops.models import EvalCase, EvalDataset
5
+
6
+
7
+ def test_summary_string_renders():
8
+ ds = EvalDataset(
9
+ version=1,
10
+ project="test-agent",
11
+ cases=[EvalCase(id="c1", input="hi", expected_output_contains=["hi"])],
12
+ )
13
+
14
+ def agent(inp: dict) -> dict:
15
+ return {"output": inp.get("input", "")}
16
+
17
+ summary = EvalSuite.run(dataset=ds, agent=agent, verbose=False)
18
+ text = summary.summary()
19
+ assert "test-agent" in text
20
+ assert "Task completion" in text