progress-observability 1.4.6__tar.gz → 1.5.1__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 (19) hide show
  1. {progress_observability-1.4.6 → progress_observability-1.5.1}/PKG-INFO +2 -2
  2. {progress_observability-1.4.6 → progress_observability-1.5.1}/pyproject.toml +2 -2
  3. progress_observability-1.5.1/src/progress/observability/genkit_span_processor.py +187 -0
  4. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/sdk.py +8 -1
  5. {progress_observability-1.4.6 → progress_observability-1.5.1}/uv.lock +1 -1
  6. {progress_observability-1.4.6 → progress_observability-1.5.1}/.gitignore +0 -0
  7. {progress_observability-1.4.6 → progress_observability-1.5.1}/.python-version +0 -0
  8. {progress_observability-1.4.6 → progress_observability-1.5.1}/LICENSE +0 -0
  9. {progress_observability-1.4.6 → progress_observability-1.5.1}/README.md +0 -0
  10. {progress_observability-1.4.6 → progress_observability-1.5.1}/notices.txt +0 -0
  11. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/__init__.py +0 -0
  12. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/constants.py +0 -0
  13. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/decorators.py +0 -0
  14. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/enums.py +0 -0
  15. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/exceptions.py +0 -0
  16. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/genai_message_processor.py +0 -0
  17. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/helpers.py +0 -0
  18. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/model_fix_processor.py +0 -0
  19. {progress_observability-1.4.6 → progress_observability-1.5.1}/src/progress/observability/tags.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: progress-observability
3
- Version: 1.4.6
3
+ Version: 1.5.1
4
4
  Summary: Progress Observability instrumentation for Python agents
5
5
  Project-URL: Homepage, https://github.com/telerik/agentclarity
6
6
  Project-URL: Repository, https://github.com/telerik/agentclarity
7
- Project-URL: Documentation, https://docs.telerik.com/agentclarity
7
+ Project-URL: Documentation, https://www.telerik.com/ai-observability-platform/documentation/sdk/python
8
8
  Project-URL: Bug Reports, https://github.com/telerik/agentclarity/issues
9
9
  License: MIT
10
10
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "progress-observability"
7
- version = "1.4.6"
7
+ version = "1.5.1"
8
8
  description = "Progress Observability instrumentation for Python agents"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -17,7 +17,7 @@ dependencies = [
17
17
  [project.urls]
18
18
  Homepage = "https://github.com/telerik/agentclarity"
19
19
  Repository = "https://github.com/telerik/agentclarity"
20
- Documentation = "https://docs.telerik.com/agentclarity"
20
+ Documentation = "https://www.telerik.com/ai-observability-platform/documentation/sdk/python"
21
21
  "Bug Reports" = "https://github.com/telerik/agentclarity/issues"
22
22
 
23
23
  [tool.uv]
@@ -0,0 +1,187 @@
1
+ """Normalize native Genkit spans into AgentClarity attributes."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from .helpers import get_writable_attributes
7
+
8
+
9
+ _PROVIDERS = {
10
+ "anthropic": "Anthropic",
11
+ "bedrock": "AWS",
12
+ "cohere": "Cohere",
13
+ "googleai": "Google",
14
+ "ollama": "Ollama",
15
+ "openai": "OpenAI",
16
+ "vertexai": "Google",
17
+ }
18
+
19
+
20
+ def _is_number(value: Any) -> bool:
21
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
22
+
23
+
24
+ def _parse_json(value: Any) -> dict[str, Any] | None:
25
+ if not isinstance(value, str):
26
+ return None
27
+ try:
28
+ parsed = json.loads(value)
29
+ except (TypeError, ValueError):
30
+ return None
31
+ return parsed if isinstance(parsed, dict) else None
32
+
33
+
34
+ def _normalize_role(role: Any) -> str:
35
+ if role == "model":
36
+ return "assistant"
37
+ return role if isinstance(role, str) else "unknown"
38
+
39
+
40
+ def _merge_consecutive_text_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
41
+ # Streaming responses (e.g. Genkit's generate_stream) record the final message content
42
+ # as a sequence of raw text deltas rather than one coalesced string. Merge adjacent
43
+ # text parts so consumers see the reassembled text instead of fragmented chunks.
44
+ merged: list[dict[str, Any]] = []
45
+ for part in parts:
46
+ previous = merged[-1] if merged else None
47
+ if part.get("type") == "text" and previous is not None and previous.get("type") == "text":
48
+ previous["content"] = f"{previous.get('content', '')}{part.get('content', '')}"
49
+ continue
50
+ merged.append(dict(part))
51
+ return merged
52
+
53
+
54
+ def _normalize_parts(content: Any) -> list[dict[str, Any]]:
55
+ if not isinstance(content, list):
56
+ return [{"type": "text", "content": content}] if isinstance(content, str) else []
57
+
58
+ parts: list[dict[str, Any]] = []
59
+ for item in content:
60
+ if not isinstance(item, dict):
61
+ continue
62
+ if isinstance(item.get("text"), str):
63
+ parts.append({"type": "text", "content": item["text"]})
64
+ elif isinstance(item.get("toolRequest"), dict):
65
+ request = item["toolRequest"]
66
+ part: dict[str, Any] = {"type": "tool_call"}
67
+ if isinstance(request.get("name"), str):
68
+ part["name"] = request["name"]
69
+ if "input" in request:
70
+ part["arguments"] = request["input"]
71
+ parts.append(part)
72
+ elif isinstance(item.get("toolResponse"), dict):
73
+ response = item["toolResponse"]
74
+ part = {"type": "tool_call_response"}
75
+ if isinstance(response.get("name"), str):
76
+ part["name"] = response["name"]
77
+ if "output" in response:
78
+ part["response"] = response["output"]
79
+ parts.append(part)
80
+ else:
81
+ parts.append({"type": item.get("type", "unknown"), **item})
82
+ return _merge_consecutive_text_parts(parts)
83
+
84
+
85
+ def _normalize_messages(value: Any) -> list[dict[str, Any]]:
86
+ if not isinstance(value, list):
87
+ return []
88
+ return [
89
+ {"role": _normalize_role(message.get("role")), "parts": _normalize_parts(message.get("content"))}
90
+ for message in value
91
+ if isinstance(message, dict)
92
+ ]
93
+
94
+
95
+ class GenkitSpanProcessor:
96
+ """Post-process native Genkit action spans before export."""
97
+
98
+ def __init__(self, trace_content: bool = True) -> None:
99
+ self._trace_content = trace_content
100
+
101
+ def on_end(self, span: Any) -> None:
102
+ attributes = get_writable_attributes(span)
103
+ if not attributes or "genkit:type" not in attributes:
104
+ return
105
+
106
+ subtype = attributes.get("genkit:metadata:subtype")
107
+ if subtype == "flow":
108
+ attributes.setdefault("traceloop.span.kind", "workflow")
109
+ return
110
+ if subtype == "tool":
111
+ attributes.setdefault("traceloop.span.kind", "tool")
112
+ attributes.setdefault("gen_ai.operation.name", "execute_tool")
113
+ tool_name = attributes.get("genkit:name")
114
+ if isinstance(tool_name, str) and tool_name:
115
+ attributes.setdefault("gen_ai.tool.name", tool_name)
116
+ if self._trace_content:
117
+ tool_input = attributes.get("genkit:input")
118
+ if isinstance(tool_input, str):
119
+ attributes.setdefault("gen_ai.tool.call.arguments", tool_input)
120
+ tool_output = attributes.get("genkit:output")
121
+ if isinstance(tool_output, str):
122
+ attributes.setdefault("gen_ai.tool.call.result", tool_output)
123
+ return
124
+ if subtype != "model":
125
+ return
126
+
127
+ name = attributes.get("genkit:name")
128
+ if not isinstance(name, str) or not name:
129
+ return
130
+
131
+ separator = name.find("/")
132
+ if separator >= 0:
133
+ plugin = name[:separator].lower()
134
+ model = name[separator + 1 :]
135
+ if "gen_ai.provider.name" not in attributes and plugin:
136
+ attributes["gen_ai.provider.name"] = _PROVIDERS.get(plugin, plugin[:1].upper() + plugin[1:])
137
+ else:
138
+ model = name
139
+ attributes.setdefault("gen_ai.request.model", model)
140
+ attributes.setdefault("gen_ai.response.model", model)
141
+
142
+ if self._trace_content:
143
+ input_data = _parse_json(attributes.get("genkit:input"))
144
+ if input_data and "gen_ai.input.messages" not in attributes:
145
+ messages = _normalize_messages(input_data.get("messages"))
146
+ if messages:
147
+ attributes["gen_ai.input.messages"] = json.dumps(messages)
148
+
149
+ output_data = _parse_json(attributes.get("genkit:output"))
150
+ if not output_data:
151
+ return
152
+ if self._trace_content:
153
+ output_message = output_data.get("message")
154
+ if output_message is None and isinstance(output_data.get("candidates"), list):
155
+ candidates = output_data["candidates"]
156
+ if candidates and isinstance(candidates[0], dict):
157
+ output_message = candidates[0].get("message")
158
+ if isinstance(output_message, dict) and "gen_ai.output.messages" not in attributes:
159
+ messages = _normalize_messages([output_message])
160
+ if messages:
161
+ attributes["gen_ai.output.messages"] = json.dumps(messages)
162
+
163
+ usage = output_data.get("usage")
164
+ if isinstance(usage, dict):
165
+ input_tokens = usage.get("inputTokens")
166
+ output_tokens = usage.get("outputTokens")
167
+ thoughts_tokens = usage.get("thoughtsTokens")
168
+ total_tokens = usage.get("totalTokens")
169
+ if _is_number(input_tokens) and "gen_ai.usage.input_tokens" not in attributes:
170
+ attributes["gen_ai.usage.input_tokens"] = int(input_tokens)
171
+ if (
172
+ _is_number(thoughts_tokens)
173
+ and "gen_ai.usage.reasoning.output_tokens" not in attributes
174
+ ):
175
+ attributes["gen_ai.usage.reasoning.output_tokens"] = int(thoughts_tokens)
176
+ if _is_number(output_tokens) and "gen_ai.usage.output_tokens" not in attributes:
177
+ # Genkit providers differ on whether outputTokens includes thoughtsTokens.
178
+ # totalTokens disambiguates the two representations when available.
179
+ thoughts_included = (
180
+ _is_number(total_tokens)
181
+ and _is_number(input_tokens)
182
+ and int(total_tokens) == int(input_tokens) + int(output_tokens)
183
+ )
184
+ effective_output_tokens = int(output_tokens) + (
185
+ 0 if thoughts_included or not _is_number(thoughts_tokens) else int(thoughts_tokens)
186
+ )
187
+ attributes["gen_ai.usage.output_tokens"] = effective_output_tokens
@@ -32,6 +32,7 @@ from .constants import DEFAULTS
32
32
  from .helpers import init_environment, setup_api_key_headers, patch_traceloop_modules, clear_sdk_env_vars
33
33
  from .model_fix_processor import ModelFixProcessor
34
34
  from .genai_message_processor import GenAIMessageProcessor
35
+ from .genkit_span_processor import GenkitSpanProcessor
35
36
  from .exceptions import (
36
37
  EndpointValidationError,
37
38
  InvalidPortError,
@@ -279,16 +280,22 @@ class Observability:
279
280
  handler.addFilter(lambda record: record.levelno == logging.DEBUG)
280
281
  pkg_logger.addHandler(handler)
281
282
 
283
+ # Resolve the effective trace_content setting (init_environment() already
284
+ # applied env var precedence and may have set TRACELOOP_TRACE_CONTENT).
285
+ trace_content_enabled = os.getenv("TRACELOOP_TRACE_CONTENT", "true").lower() == "true"
286
+
282
287
  # Create processors with debug mode
283
288
  genai_message_processor = GenAIMessageProcessor(debug=debug_enabled)
289
+ genkit_span_processor = GenkitSpanProcessor(trace_content=trace_content_enabled)
284
290
  model_fix_processor = ModelFixProcessor(debug=debug_enabled)
285
291
  cls._model_fix_processor = model_fix_processor
286
292
 
287
- # Chain processors: message normalization first, then model fixes
293
+ # Chain processors: Genkit normalization, message normalization, then model fixes.
288
294
  # Preserve any existing user-provided callback by chaining with it
289
295
  existing_callback = kwargs.get('span_postprocess_callback')
290
296
 
291
297
  def chained_callback(span):
298
+ genkit_span_processor.on_end(span)
292
299
  genai_message_processor.on_end(span)
293
300
  model_fix_processor.on_end(span)
294
301
  if existing_callback:
@@ -1544,7 +1544,7 @@ wheels = [
1544
1544
 
1545
1545
  [[package]]
1546
1546
  name = "progress-observability"
1547
- version = "1.4.6"
1547
+ version = "1.5.1"
1548
1548
  source = { editable = "." }
1549
1549
  dependencies = [
1550
1550
  { name = "httpx" },