progress-observability 1.5.0__tar.gz → 1.5.3__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.5.0 → progress_observability-1.5.3}/PKG-INFO +6 -3
  2. {progress_observability-1.5.0 → progress_observability-1.5.3}/README.md +3 -1
  3. {progress_observability-1.5.0 → progress_observability-1.5.3}/pyproject.toml +3 -2
  4. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/genkit_span_processor.py +70 -15
  5. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/sdk.py +130 -9
  6. {progress_observability-1.5.0 → progress_observability-1.5.3}/uv.lock +4 -2
  7. {progress_observability-1.5.0 → progress_observability-1.5.3}/.gitignore +0 -0
  8. {progress_observability-1.5.0 → progress_observability-1.5.3}/.python-version +0 -0
  9. {progress_observability-1.5.0 → progress_observability-1.5.3}/LICENSE +0 -0
  10. {progress_observability-1.5.0 → progress_observability-1.5.3}/notices.txt +0 -0
  11. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/__init__.py +0 -0
  12. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/constants.py +0 -0
  13. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/decorators.py +0 -0
  14. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/enums.py +0 -0
  15. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/exceptions.py +0 -0
  16. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/genai_message_processor.py +0 -0
  17. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/helpers.py +0 -0
  18. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/model_fix_processor.py +0 -0
  19. {progress_observability-1.5.0 → progress_observability-1.5.3}/src/progress/observability/tags.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: progress-observability
3
- Version: 1.5.0
3
+ Version: 1.5.3
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
@@ -10,7 +10,8 @@ License: MIT
10
10
  License-File: LICENSE
11
11
  License-File: notices.txt
12
12
  Requires-Python: <4.0.0,>=3.10.0
13
- Requires-Dist: httpx>=0.23.0
13
+ Requires-Dist: httpx==0.28.1
14
+ Requires-Dist: requests==2.34.2
14
15
  Requires-Dist: traceloop-sdk==0.62.3
15
16
  Description-Content-Type: text/markdown
16
17
 
@@ -50,7 +51,7 @@ Common options (for `Observability.instrument`):
50
51
  - `app_name` - Application name for telemetry
51
52
  - `endpoint` - OpenTelemetry collector endpoint
52
53
  - `api_key` - Authentication key
53
- - `debug` - Enable debug logging
54
+ - `debug` - Show sanitized OTLP trace-delivery failures on standard error (default: `False`)
54
55
  - `instruments` / `block_instruments` - Control what gets traced via `ObservabilityInstruments`
55
56
  - `trace_content` - when set to `False` will stop sending LLM prompt/responses as part of the telemetry data
56
57
  - `additional_tags` - custom tags (see below)
@@ -61,8 +62,10 @@ Environment overrides (optional):
61
62
  - `OBSERVABILITY_ENDPOINT` sets a custom endpoint for collecting data (e.g. "https://collector.observability.progress.com:443")
62
63
  - `OBSERVABILITY_API_KEY` sets the authentication key to use when sending data
63
64
  - `OBSERVABILITY_TRACE_CONTENT` when set to `false` will stop sending LLM prompt/responses as part of the telemetry data
65
+ - `OBSERVABILITY_DEBUG` enables sanitized trace-delivery errors when set to `1`, `true`, or `yes`
64
66
 
65
67
  Auth headers are added automatically for HTTP(S) endpoints when api_key is provided.
68
+ Invalid endpoints and missing or empty API keys raise during initialization. Delivery failures do not terminate the application and remain silent unless debug logging is enabled. Debug messages never include API keys, authorization headers, or telemetry payloads.
66
69
 
67
70
  ## Custom Tags
68
71
 
@@ -34,7 +34,7 @@ Common options (for `Observability.instrument`):
34
34
  - `app_name` - Application name for telemetry
35
35
  - `endpoint` - OpenTelemetry collector endpoint
36
36
  - `api_key` - Authentication key
37
- - `debug` - Enable debug logging
37
+ - `debug` - Show sanitized OTLP trace-delivery failures on standard error (default: `False`)
38
38
  - `instruments` / `block_instruments` - Control what gets traced via `ObservabilityInstruments`
39
39
  - `trace_content` - when set to `False` will stop sending LLM prompt/responses as part of the telemetry data
40
40
  - `additional_tags` - custom tags (see below)
@@ -45,8 +45,10 @@ Environment overrides (optional):
45
45
  - `OBSERVABILITY_ENDPOINT` sets a custom endpoint for collecting data (e.g. "https://collector.observability.progress.com:443")
46
46
  - `OBSERVABILITY_API_KEY` sets the authentication key to use when sending data
47
47
  - `OBSERVABILITY_TRACE_CONTENT` when set to `false` will stop sending LLM prompt/responses as part of the telemetry data
48
+ - `OBSERVABILITY_DEBUG` enables sanitized trace-delivery errors when set to `1`, `true`, or `yes`
48
49
 
49
50
  Auth headers are added automatically for HTTP(S) endpoints when api_key is provided.
51
+ Invalid endpoints and missing or empty API keys raise during initialization. Delivery failures do not terminate the application and remain silent unless debug logging is enabled. Debug messages never include API keys, authorization headers, or telemetry payloads.
50
52
 
51
53
  ## Custom Tags
52
54
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "progress-observability"
7
- version = "1.5.0"
7
+ version = "1.5.3"
8
8
  description = "Progress Observability instrumentation for Python agents"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -12,7 +12,8 @@ license-files = ["LICENSE", "notices.txt"]
12
12
  requires-python = ">=3.10.0,<4.0.0"
13
13
  dependencies = [
14
14
  "traceloop-sdk==0.62.3",
15
- "httpx>=0.23.0",
15
+ "httpx==0.28.1",
16
+ "requests==2.34.2",
16
17
  ]
17
18
  [project.urls]
18
19
  Homepage = "https://github.com/telerik/agentclarity"
@@ -37,6 +37,20 @@ def _normalize_role(role: Any) -> str:
37
37
  return role if isinstance(role, str) else "unknown"
38
38
 
39
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
+
40
54
  def _normalize_parts(content: Any) -> list[dict[str, Any]]:
41
55
  if not isinstance(content, list):
42
56
  return [{"type": "text", "content": content}] if isinstance(content, str) else []
@@ -65,7 +79,7 @@ def _normalize_parts(content: Any) -> list[dict[str, Any]]:
65
79
  parts.append(part)
66
80
  else:
67
81
  parts.append({"type": item.get("type", "unknown"), **item})
68
- return parts
82
+ return _merge_consecutive_text_parts(parts)
69
83
 
70
84
 
71
85
  def _normalize_messages(value: Any) -> list[dict[str, Any]]:
@@ -81,6 +95,9 @@ def _normalize_messages(value: Any) -> list[dict[str, Any]]:
81
95
  class GenkitSpanProcessor:
82
96
  """Post-process native Genkit action spans before export."""
83
97
 
98
+ def __init__(self, trace_content: bool = True) -> None:
99
+ self._trace_content = trace_content
100
+
84
101
  def on_end(self, span: Any) -> None:
85
102
  attributes = get_writable_attributes(span)
86
103
  if not attributes or "genkit:type" not in attributes:
@@ -92,8 +109,44 @@ class GenkitSpanProcessor:
92
109
  return
93
110
  if subtype == "tool":
94
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)
95
123
  return
96
124
  if subtype != "model":
125
+ # The root span of ai.generate()/ai.generate_stream() calls made outside of an
126
+ # explicit flow isn't tagged with a subtype (it's registered as a "util"
127
+ # action), so it falls through every branch above and is left without a span
128
+ # kind or provider. Since genkit:input carries the requested model name, derive
129
+ # both from it the same way the model span does. Genkit's built-in generate
130
+ # action always reports subtype "util" (or omits it for nested turns); guard
131
+ # on that so a user-defined action merely named "generate" with a different
132
+ # subtype (e.g. "prompt") isn't misclassified.
133
+ if attributes.get("genkit:name") == "generate" and subtype in (None, "util"):
134
+ attributes.setdefault("traceloop.span.kind", "agent")
135
+ input_data = _parse_json(attributes.get("genkit:input"))
136
+ model_name = input_data.get("model") if input_data else None
137
+ if isinstance(model_name, str) and model_name:
138
+ separator = model_name.find("/")
139
+ if separator >= 0:
140
+ plugin = model_name[:separator].lower()
141
+ model = model_name[separator + 1 :]
142
+ if "gen_ai.provider.name" not in attributes and plugin:
143
+ attributes["gen_ai.provider.name"] = _PROVIDERS.get(
144
+ plugin, plugin[:1].upper() + plugin[1:]
145
+ )
146
+ else:
147
+ model = model_name
148
+ attributes.setdefault("gen_ai.request.model", model)
149
+ attributes.setdefault("gen_ai.response.model", model)
97
150
  return
98
151
 
99
152
  name = attributes.get("genkit:name")
@@ -111,24 +164,26 @@ class GenkitSpanProcessor:
111
164
  attributes.setdefault("gen_ai.request.model", model)
112
165
  attributes.setdefault("gen_ai.response.model", model)
113
166
 
114
- input_data = _parse_json(attributes.get("genkit:input"))
115
- if input_data and "gen_ai.input.messages" not in attributes:
116
- messages = _normalize_messages(input_data.get("messages"))
117
- if messages:
118
- attributes["gen_ai.input.messages"] = json.dumps(messages)
167
+ if self._trace_content:
168
+ input_data = _parse_json(attributes.get("genkit:input"))
169
+ if input_data and "gen_ai.input.messages" not in attributes:
170
+ messages = _normalize_messages(input_data.get("messages"))
171
+ if messages:
172
+ attributes["gen_ai.input.messages"] = json.dumps(messages)
119
173
 
120
174
  output_data = _parse_json(attributes.get("genkit:output"))
121
175
  if not output_data:
122
176
  return
123
- output_message = output_data.get("message")
124
- if output_message is None and isinstance(output_data.get("candidates"), list):
125
- candidates = output_data["candidates"]
126
- if candidates and isinstance(candidates[0], dict):
127
- output_message = candidates[0].get("message")
128
- if isinstance(output_message, dict) and "gen_ai.output.messages" not in attributes:
129
- messages = _normalize_messages([output_message])
130
- if messages:
131
- attributes["gen_ai.output.messages"] = json.dumps(messages)
177
+ if self._trace_content:
178
+ output_message = output_data.get("message")
179
+ if output_message is None and isinstance(output_data.get("candidates"), list):
180
+ candidates = output_data["candidates"]
181
+ if candidates and isinstance(candidates[0], dict):
182
+ output_message = candidates[0].get("message")
183
+ if isinstance(output_message, dict) and "gen_ai.output.messages" not in attributes:
184
+ messages = _normalize_messages([output_message])
185
+ if messages:
186
+ attributes["gen_ai.output.messages"] = json.dumps(messages)
132
187
 
133
188
  usage = output_data.get("usage")
134
189
  if isinstance(usage, dict):
@@ -19,6 +19,7 @@ Quick Start:
19
19
 
20
20
  """
21
21
 
22
+ import re
22
23
  import sys
23
24
  import os
24
25
  from contextlib import redirect_stdout, redirect_stderr
@@ -63,6 +64,88 @@ class Observability:
63
64
  _tags_processor = None # TagsSpanProcessor instance for tag propagation
64
65
  SDK = Traceloop
65
66
 
67
+ _EXPORTER_LOGGER_NAME = "opentelemetry.exporter.otlp.proto.http.trace_exporter"
68
+ _EXPORTER_HANDLER_MARKER = "_progress_observability_handler"
69
+ _EXPORTER_FILTER_MARKER = "_progress_observability_filter"
70
+
71
+ @staticmethod
72
+ def _sanitize_exporter_message(message: str) -> str:
73
+ """Map exporter failures to useful messages without exposing request data."""
74
+ normalized = message.lower()
75
+
76
+ status_match = re.search(r"\b(401|403)\b", normalized)
77
+ if status_match:
78
+ status_messages = {
79
+ "401": "HTTP 401 Unauthorized.",
80
+ "403": "HTTP 403 Forbidden.",
81
+ }
82
+ return status_messages[status_match.group(1)]
83
+ if "timed out" in normalized or "timeout" in normalized:
84
+ return "request timed out."
85
+ if any(
86
+ term in normalized for term in ("name resolution", "resolve host", "dns")
87
+ ):
88
+ return "collector host could not be resolved."
89
+ if any(
90
+ term in normalized
91
+ for term in (
92
+ "connection refused",
93
+ "unreachable",
94
+ "failed to establish a new connection",
95
+ )
96
+ ):
97
+ return "collector is unreachable."
98
+ return "failed to export traces."
99
+
100
+ @classmethod
101
+ def _configure_exporter_logging(cls, debug_enabled: bool) -> None:
102
+ exporter_logger = logging.getLogger(cls._EXPORTER_LOGGER_NAME)
103
+
104
+ for handler in list(exporter_logger.handlers):
105
+ if getattr(handler, cls._EXPORTER_HANDLER_MARKER, False):
106
+ exporter_logger.removeHandler(handler)
107
+ handler.close()
108
+ for logger_filter in list(exporter_logger.filters):
109
+ if getattr(logger_filter, cls._EXPORTER_FILTER_MARKER, False):
110
+ exporter_logger.removeFilter(logger_filter)
111
+
112
+ exporter_logger.propagate = False
113
+ exporter_logger.disabled = not debug_enabled
114
+
115
+ if not debug_enabled:
116
+ return
117
+
118
+ class SanitizingFilter(logging.Filter):
119
+ _progress_observability_filter = True
120
+
121
+ def filter(self, record: logging.LogRecord) -> bool:
122
+ record.msg = cls._sanitize_exporter_message(record.getMessage())
123
+ record.args = ()
124
+ record.exc_info = None
125
+ record.exc_text = None
126
+ record.stack_info = None
127
+ return True
128
+
129
+ exporter_logger.setLevel(logging.ERROR)
130
+ handler = logging.StreamHandler(sys.stderr)
131
+ handler.setLevel(logging.ERROR)
132
+ handler.setFormatter(
133
+ logging.Formatter(
134
+ "[Observability] Failed to export traces: %(message)s"
135
+ )
136
+ )
137
+ setattr(handler, cls._EXPORTER_HANDLER_MARKER, True)
138
+ exporter_logger.addFilter(SanitizingFilter())
139
+ exporter_logger.addHandler(handler)
140
+
141
+ @staticmethod
142
+ def _report_configuration_failure(debug_enabled: bool, message: str) -> None:
143
+ if debug_enabled:
144
+ print(
145
+ f"[Observability] Configuration error: {message}",
146
+ file=sys.stderr,
147
+ )
148
+
66
149
  @staticmethod
67
150
  def _validate_endpoint(endpoint: Optional[str]) -> str:
68
151
  """
@@ -131,7 +214,7 @@ class Observability:
131
214
  return endpoint
132
215
 
133
216
  @staticmethod
134
- def _validate_api_key(api_key: Optional[str]) -> None:
217
+ def _validate_api_key(api_key: Optional[str]) -> str:
135
218
  """Validate the API key is provided via parameter or environment variable.
136
219
  Args:
137
220
  api_key: The API key parameter value to validate
@@ -258,17 +341,49 @@ class Observability:
258
341
  # Always allow re-initialization to support different instrument configurations
259
342
  # The underlying traceloop SDK will handle duplicate initialization safely
260
343
 
261
- # Validate and resolve endpoint and API key before processing
262
- endpoint = cls._validate_endpoint(endpoint)
263
- api_key = cls._validate_api_key(api_key)
264
-
265
- app_name = init_environment(app_name, trace_content)
266
-
267
344
  # Ensure debug logs are visible when explicitly requested.
268
345
  # Honor both the debug parameter and the OBSERVABILITY_DEBUG env var.
269
346
  # Only raise the progress.observability logger - not the root logger,
270
347
  # which would make every third-party library verbose.
271
- debug_enabled = debug or os.getenv("OBSERVABILITY_DEBUG", "").lower() in ("1", "true", "yes")
348
+ debug_enabled = debug or os.getenv("OBSERVABILITY_DEBUG", "").lower() in (
349
+ "1",
350
+ "true",
351
+ "yes",
352
+ )
353
+ cls._configure_exporter_logging(debug_enabled)
354
+
355
+ endpoint_error: Optional[Exception] = None
356
+ api_key_error: Optional[Exception] = None
357
+
358
+ try:
359
+ endpoint = cls._validate_endpoint(endpoint)
360
+ except EndpointValidationError as exc:
361
+ endpoint_error = exc
362
+ cls._report_configuration_failure(
363
+ debug_enabled,
364
+ "endpoint is invalid. Use a valid http/https URL with host and port.",
365
+ )
366
+
367
+ try:
368
+ api_key = cls._validate_api_key(api_key)
369
+ except (TypeError, ValueError) as exc:
370
+ api_key_error = exc
371
+ cls._report_configuration_failure(
372
+ debug_enabled,
373
+ "API key is missing or empty.",
374
+ )
375
+
376
+ if endpoint_error and api_key_error:
377
+ raise ValueError(
378
+ "Invalid configuration: endpoint is invalid and API key is missing or empty."
379
+ ) from api_key_error
380
+ if endpoint_error:
381
+ raise endpoint_error
382
+ if api_key_error:
383
+ raise api_key_error
384
+
385
+ app_name = init_environment(app_name, trace_content)
386
+
272
387
  if debug_enabled:
273
388
  pkg_logger = logging.getLogger("progress.observability")
274
389
  pkg_logger.setLevel(logging.DEBUG)
@@ -280,9 +395,13 @@ class Observability:
280
395
  handler.addFilter(lambda record: record.levelno == logging.DEBUG)
281
396
  pkg_logger.addHandler(handler)
282
397
 
398
+ # Resolve the effective trace_content setting (init_environment() already
399
+ # applied env var precedence and may have set TRACELOOP_TRACE_CONTENT).
400
+ trace_content_enabled = os.getenv("TRACELOOP_TRACE_CONTENT", "true").lower() == "true"
401
+
283
402
  # Create processors with debug mode
284
403
  genai_message_processor = GenAIMessageProcessor(debug=debug_enabled)
285
- genkit_span_processor = GenkitSpanProcessor()
404
+ genkit_span_processor = GenkitSpanProcessor(trace_content=trace_content_enabled)
286
405
  model_fix_processor = ModelFixProcessor(debug=debug_enabled)
287
406
  cls._model_fix_processor = model_fix_processor
288
407
 
@@ -376,6 +495,8 @@ class Observability:
376
495
  After shutdown, subsequent calls to instrument() will reinitialize
377
496
  the instrumentation.
378
497
  """
498
+ cls._configure_exporter_logging(False)
499
+
379
500
  if not cls._initialized:
380
501
  return True
381
502
 
@@ -1544,16 +1544,18 @@ wheels = [
1544
1544
 
1545
1545
  [[package]]
1546
1546
  name = "progress-observability"
1547
- version = "1.5.0"
1547
+ version = "1.5.3"
1548
1548
  source = { editable = "." }
1549
1549
  dependencies = [
1550
1550
  { name = "httpx" },
1551
+ { name = "requests" },
1551
1552
  { name = "traceloop-sdk" },
1552
1553
  ]
1553
1554
 
1554
1555
  [package.metadata]
1555
1556
  requires-dist = [
1556
- { name = "httpx", specifier = ">=0.23.0" },
1557
+ { name = "httpx", specifier = "==0.28.1" },
1558
+ { name = "requests", specifier = "==2.34.2" },
1557
1559
  { name = "traceloop-sdk", specifier = "==0.62.3" },
1558
1560
  ]
1559
1561