remdb 0.3.103__py3-none-any.whl → 0.3.141__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.

Potentially problematic release.


This version of remdb might be problematic. Click here for more details.

Files changed (74) hide show
  1. rem/agentic/agents/sse_simulator.py +2 -0
  2. rem/agentic/context.py +51 -27
  3. rem/agentic/mcp/tool_wrapper.py +155 -18
  4. rem/agentic/otel/setup.py +93 -4
  5. rem/agentic/providers/phoenix.py +371 -108
  6. rem/agentic/providers/pydantic_ai.py +195 -46
  7. rem/agentic/schema.py +361 -21
  8. rem/agentic/tools/rem_tools.py +3 -3
  9. rem/api/main.py +85 -16
  10. rem/api/mcp_router/resources.py +1 -1
  11. rem/api/mcp_router/server.py +18 -4
  12. rem/api/mcp_router/tools.py +394 -16
  13. rem/api/routers/admin.py +218 -1
  14. rem/api/routers/chat/completions.py +280 -7
  15. rem/api/routers/chat/models.py +81 -7
  16. rem/api/routers/chat/otel_utils.py +33 -0
  17. rem/api/routers/chat/sse_events.py +17 -1
  18. rem/api/routers/chat/streaming.py +177 -3
  19. rem/api/routers/feedback.py +142 -329
  20. rem/api/routers/query.py +360 -0
  21. rem/api/routers/shared_sessions.py +13 -13
  22. rem/cli/commands/README.md +237 -64
  23. rem/cli/commands/cluster.py +1808 -0
  24. rem/cli/commands/configure.py +4 -7
  25. rem/cli/commands/db.py +354 -143
  26. rem/cli/commands/experiments.py +436 -30
  27. rem/cli/commands/process.py +14 -8
  28. rem/cli/commands/schema.py +92 -45
  29. rem/cli/commands/session.py +336 -0
  30. rem/cli/dreaming.py +2 -2
  31. rem/cli/main.py +29 -6
  32. rem/config.py +8 -1
  33. rem/models/core/experiment.py +54 -0
  34. rem/models/core/rem_query.py +5 -2
  35. rem/models/entities/ontology.py +1 -1
  36. rem/models/entities/ontology_config.py +1 -1
  37. rem/models/entities/shared_session.py +2 -28
  38. rem/registry.py +10 -4
  39. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  40. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  41. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  42. rem/services/content/service.py +30 -8
  43. rem/services/embeddings/api.py +4 -4
  44. rem/services/embeddings/worker.py +16 -16
  45. rem/services/phoenix/client.py +59 -18
  46. rem/services/postgres/README.md +151 -26
  47. rem/services/postgres/__init__.py +2 -1
  48. rem/services/postgres/diff_service.py +531 -0
  49. rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
  50. rem/services/postgres/schema_generator.py +205 -4
  51. rem/services/postgres/service.py +6 -6
  52. rem/services/rem/parser.py +44 -9
  53. rem/services/rem/service.py +36 -2
  54. rem/services/session/compression.py +7 -0
  55. rem/services/session/reload.py +1 -1
  56. rem/settings.py +288 -16
  57. rem/sql/background_indexes.sql +19 -24
  58. rem/sql/migrations/001_install.sql +252 -69
  59. rem/sql/migrations/002_install_models.sql +2197 -619
  60. rem/sql/migrations/003_optional_extensions.sql +326 -0
  61. rem/sql/migrations/004_cache_system.sql +548 -0
  62. rem/utils/__init__.py +18 -0
  63. rem/utils/date_utils.py +2 -2
  64. rem/utils/schema_loader.py +110 -15
  65. rem/utils/sql_paths.py +146 -0
  66. rem/utils/vision.py +1 -1
  67. rem/workers/__init__.py +3 -1
  68. rem/workers/db_listener.py +579 -0
  69. rem/workers/unlogged_maintainer.py +463 -0
  70. {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/METADATA +300 -215
  71. {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/RECORD +73 -64
  72. rem/sql/migrations/003_seed_default_user.sql +0 -48
  73. {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/WHEEL +0 -0
  74. {remdb-0.3.103.dist-info → remdb-0.3.141.dist-info}/entry_points.txt +0 -0
@@ -265,6 +265,8 @@ async def stream_simulator_events(
265
265
  message_id=message_id,
266
266
  in_reply_to=in_reply_to,
267
267
  session_id=session_id,
268
+ # Session info
269
+ session_name="SSE Demo Session",
268
270
  # Quality indicators
269
271
  confidence=0.95,
270
272
  sources=["rem/api/routers/chat/sse_events.py", "rem/agentic/agents/sse_simulator.py"],
rem/agentic/context.py CHANGED
@@ -2,10 +2,18 @@
2
2
  Agent execution context and configuration.
3
3
 
4
4
  Design pattern for session context that can be constructed from:
5
- - HTTP headers (X-User-Id, X-Session-Id, X-Model-Name)
5
+ - HTTP headers (X-User-Id, X-Session-Id, X-Model-Name, X-Is-Eval, etc.)
6
6
  - Direct instantiation for testing/CLI
7
7
 
8
- Key Design Pattern
8
+ Headers Mapping:
9
+ X-User-Id → context.user_id
10
+ X-Tenant-Id → context.tenant_id (default: "default")
11
+ X-Session-Id → context.session_id
12
+ X-Agent-Schema → context.agent_schema_uri (default: "rem")
13
+ X-Model-Name → context.default_model
14
+ X-Is-Eval → context.is_eval (marks session as evaluation)
15
+
16
+ Key Design Pattern:
9
17
  - AgentContext is passed to agent factory, not stored in agents
10
18
  - Enables session tracking across API, CLI, and test execution
11
19
  - Supports header-based configuration override (model, schema URI)
@@ -66,6 +74,11 @@ class AgentContext(BaseModel):
66
74
  description="Agent schema URI (e.g., 'rem-agents-query-agent')",
67
75
  )
68
76
 
77
+ is_eval: bool = Field(
78
+ default=False,
79
+ description="Whether this is an evaluation session (set via X-Is-Eval header)",
80
+ )
81
+
69
82
  model_config = {"populate_by_name": True}
70
83
 
71
84
  @staticmethod
@@ -73,43 +86,47 @@ class AgentContext(BaseModel):
73
86
  user_id: str | None,
74
87
  source: str = "context",
75
88
  default: str | None = None,
76
- ) -> str:
89
+ ) -> str | None:
77
90
  """
78
- Get user_id or fallback to default with logging.
91
+ Get user_id or return None for anonymous access.
79
92
 
80
- Centralized helper for consistent user_id fallback behavior across
81
- API endpoints, MCP tools, CLI commands, and services.
93
+ User ID convention:
94
+ - user_id is a deterministic UUID5 hash of the user's email address
95
+ - Use rem.utils.user_id.email_to_user_id(email) to generate
96
+ - The JWT's `sub` claim is NOT directly used as user_id
97
+ - Authentication middleware extracts email from JWT and hashes it
98
+
99
+ When user_id is None, queries return data with user_id IS NULL
100
+ (shared/public data). This is intentional - no fake user IDs.
82
101
 
83
102
  Args:
84
- user_id: User identifier (may be None)
103
+ user_id: User identifier (UUID5 hash of email, may be None for anonymous)
85
104
  source: Source of the call (for logging clarity)
86
- default: Default value to use (default: settings.test.effective_user_id)
105
+ default: Explicit default (only for testing, not auto-generated)
87
106
 
88
107
  Returns:
89
- user_id if provided, otherwise default from settings
108
+ user_id if provided, explicit default if provided, otherwise None
90
109
 
91
110
  Example:
92
- # In MCP tool
93
- user_id = AgentContext.get_user_id_or_default(
94
- user_id, source="ask_rem_agent"
95
- )
96
-
97
- # In API endpoint
98
- user_id = AgentContext.get_user_id_or_default(
99
- temp_context.user_id, source="chat_completions"
100
- )
111
+ # Generate user_id from email (done by auth middleware)
112
+ from rem.utils.user_id import email_to_user_id
113
+ user_id = email_to_user_id("alice@example.com")
114
+ # -> "2c5ea4c0-4067-5fef-942d-0a20124e06d8"
101
115
 
102
- # In CLI command
116
+ # In MCP tool - anonymous user sees shared data
103
117
  user_id = AgentContext.get_user_id_or_default(
104
- args.user_id, source="rem ask"
118
+ user_id, source="ask_rem_agent"
105
119
  )
120
+ # Returns None if not authenticated -> queries WHERE user_id IS NULL
106
121
  """
107
- if user_id is None:
108
- from rem.settings import settings
109
- effective_default = default or settings.test.effective_user_id
110
- logger.debug(f"No user_id provided from {source}, using '{effective_default}'")
111
- return effective_default
112
- return user_id
122
+ if user_id is not None:
123
+ return user_id
124
+ if default is not None:
125
+ logger.debug(f"Using explicit default user_id '{default}' from {source}")
126
+ return default
127
+ # No fake user IDs - return None for anonymous/unauthenticated
128
+ logger.debug(f"No user_id from {source}, using None (anonymous/shared data)")
129
+ return None
113
130
 
114
131
  @classmethod
115
132
  def from_headers(cls, headers: dict[str, str]) -> "AgentContext":
@@ -122,6 +139,7 @@ class AgentContext(BaseModel):
122
139
  - X-Session-Id: Session identifier
123
140
  - X-Model-Name: Model override
124
141
  - X-Agent-Schema: Agent schema URI
142
+ - X-Is-Eval: Whether this is an evaluation session (true/false)
125
143
 
126
144
  Args:
127
145
  headers: Dictionary of HTTP headers (case-insensitive)
@@ -134,17 +152,23 @@ class AgentContext(BaseModel):
134
152
  "X-User-Id": "user123",
135
153
  "X-Tenant-Id": "acme-corp",
136
154
  "X-Session-Id": "sess-456",
137
- "X-Model-Name": "anthropic:claude-opus-4-20250514"
155
+ "X-Model-Name": "anthropic:claude-opus-4-20250514",
156
+ "X-Is-Eval": "true"
138
157
  }
139
158
  context = AgentContext.from_headers(headers)
140
159
  """
141
160
  # Normalize header keys to lowercase for case-insensitive lookup
142
161
  normalized = {k.lower(): v for k, v in headers.items()}
143
162
 
163
+ # Parse X-Is-Eval header (accepts "true", "1", "yes" as truthy)
164
+ is_eval_str = normalized.get("x-is-eval", "").lower()
165
+ is_eval = is_eval_str in ("true", "1", "yes")
166
+
144
167
  return cls(
145
168
  user_id=normalized.get("x-user-id"),
146
169
  tenant_id=normalized.get("x-tenant-id", "default"),
147
170
  session_id=normalized.get("x-session-id"),
148
171
  default_model=normalized.get("x-model-name") or settings.llm.default_model,
149
172
  agent_schema_uri=normalized.get("x-agent-schema"),
173
+ is_eval=is_eval,
150
174
  )
@@ -28,7 +28,12 @@ def create_pydantic_tool(func: Callable[..., Any]) -> Tool:
28
28
  return Tool(func)
29
29
 
30
30
 
31
- def create_mcp_tool_wrapper(tool_name: str, mcp_tool: Any, user_id: str | None = None) -> Tool:
31
+ def create_mcp_tool_wrapper(
32
+ tool_name: str,
33
+ mcp_tool: Any,
34
+ user_id: str | None = None,
35
+ description_suffix: str | None = None,
36
+ ) -> Tool:
32
37
  """
33
38
  Create a Pydantic AI Tool from a FastMCP FunctionTool.
34
39
 
@@ -40,6 +45,8 @@ def create_mcp_tool_wrapper(tool_name: str, mcp_tool: Any, user_id: str | None =
40
45
  tool_name: Name of the MCP tool
41
46
  mcp_tool: The FastMCP FunctionTool object
42
47
  user_id: Optional user_id to inject into tool calls
48
+ description_suffix: Optional text to append to the tool's docstring.
49
+ Used to add schema-specific context (e.g., default table for search_rem).
43
50
 
44
51
  Returns:
45
52
  A Pydantic AI Tool instance
@@ -52,7 +59,11 @@ def create_mcp_tool_wrapper(tool_name: str, mcp_tool: Any, user_id: str | None =
52
59
  sig = inspect.signature(tool_func)
53
60
  has_user_id = "user_id" in sig.parameters
54
61
 
55
- # If we need to inject user_id, create a wrapper
62
+ # Build the docstring with optional suffix
63
+ base_doc = tool_func.__doc__ or ""
64
+ final_doc = base_doc + description_suffix if description_suffix else base_doc
65
+
66
+ # If we need to inject user_id or modify docstring, create a wrapper
56
67
  # Otherwise, use the function directly for better signature preservation
57
68
  if user_id and has_user_id:
58
69
  async def wrapped_tool(**kwargs) -> Any:
@@ -69,39 +80,165 @@ def create_mcp_tool_wrapper(tool_name: str, mcp_tool: Any, user_id: str | None =
69
80
 
70
81
  # Copy signature from original function for Pydantic AI inspection
71
82
  wrapped_tool.__name__ = tool_name
72
- wrapped_tool.__doc__ = tool_func.__doc__
83
+ wrapped_tool.__doc__ = final_doc
73
84
  wrapped_tool.__annotations__ = tool_func.__annotations__
74
85
  wrapped_tool.__signature__ = sig # Important: preserve full signature
75
86
 
76
87
  logger.debug(f"Creating MCP tool wrapper with user_id injection: {tool_name}")
77
88
  return Tool(wrapped_tool)
89
+ elif description_suffix:
90
+ # Need to wrap just for docstring modification
91
+ async def wrapped_tool(**kwargs) -> Any:
92
+ """Wrapper for docstring modification."""
93
+ valid_params = set(sig.parameters.keys())
94
+ filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_params}
95
+ return await tool_func(**filtered_kwargs)
96
+
97
+ wrapped_tool.__name__ = tool_name
98
+ wrapped_tool.__doc__ = final_doc
99
+ wrapped_tool.__annotations__ = tool_func.__annotations__
100
+ wrapped_tool.__signature__ = sig
101
+
102
+ logger.debug(f"Creating MCP tool wrapper with description suffix: {tool_name}")
103
+ return Tool(wrapped_tool)
78
104
  else:
79
105
  # No injection needed - use original function directly
80
106
  logger.debug(f"Creating MCP tool wrapper (no injection): {tool_name}")
81
107
  return Tool(tool_func)
82
108
 
83
109
 
84
- def create_resource_tool(uri: str, usage: str) -> Tool:
110
+ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> Tool:
85
111
  """
86
112
  Build a Tool instance from an MCP resource URI.
87
113
 
88
- This is a placeholder for now. A real implementation would create a
89
- tool that reads the content of the resource URI.
114
+ Creates a tool that fetches the resource content when called.
115
+ Resources declared in agent YAML become callable tools - this eliminates
116
+ the artificial MCP distinction between tools and resources.
117
+
118
+ Supports both:
119
+ - Concrete URIs: "rem://schemas" -> tool with no parameters
120
+ - Template URIs: "patient-profile://field/{field_key}" -> tool with field_key parameter
90
121
 
91
122
  Args:
92
- uri: The resource URI (e.g., "rem://resources/some-id").
93
- usage: The description of how to use the tool.
123
+ uri: The resource URI (concrete or template with {variable} placeholders).
124
+ usage: The description of what this resource provides.
125
+ mcp_server: Optional FastMCP server instance to resolve resources from.
126
+ If provided, resources are resolved from this server's registry.
127
+ If not provided, falls back to REM's built-in load_resource().
94
128
 
95
129
  Returns:
96
- A Pydantic AI Tool instance.
97
- """
98
- # Placeholder function that would read the resource
99
- def read_resource():
100
- """Reads content from a resource URI."""
101
- return f"Content of {uri}"
130
+ A Pydantic AI Tool instance that fetches the resource.
102
131
 
103
- read_resource.__name__ = f"read_{uri.replace('://', '_').replace('/', '_')}"
104
- read_resource.__doc__ = usage
132
+ Example:
133
+ # Concrete URI -> no-param tool
134
+ tool = create_resource_tool("rem://schemas", "List all agent schemas")
105
135
 
106
- logger.info(f"Built resource tool: {read_resource.__name__} (uri: {uri})")
107
- return Tool(read_resource)
136
+ # Template URI -> parameterized tool
137
+ tool = create_resource_tool("patient-profile://field/{field_key}", "Get field definition", mcp_server=mcp)
138
+ # Agent calls: get_patient_profile_field(field_key="safety.suicidality")
139
+ """
140
+ import json
141
+ import re
142
+
143
+ # Extract template variables from URI (e.g., {field_key}, {domain_name})
144
+ template_vars = re.findall(r'\{([^}]+)\}', uri)
145
+
146
+ # Parse URI to create function name (strip template vars for cleaner name)
147
+ clean_uri = re.sub(r'\{[^}]+\}', '', uri)
148
+ parts = clean_uri.replace("://", "_").replace("-", "_").replace("/", "_").replace(".", "_")
149
+ parts = re.sub(r'_+', '_', parts).strip('_') # Clean up multiple underscores
150
+ func_name = f"get_{parts}"
151
+
152
+ # Build description including parameter info
153
+ description = usage or f"Fetch {uri} resource"
154
+ if template_vars:
155
+ param_desc = ", ".join(template_vars)
156
+ description = f"{description}\n\nParameters: {param_desc}"
157
+
158
+ if template_vars:
159
+ # Template URI -> create parameterized tool
160
+ async def wrapper(**kwargs: Any) -> str:
161
+ """Fetch MCP resource with substituted parameters."""
162
+ import asyncio
163
+ import inspect
164
+
165
+ # Try to resolve from MCP server's resource templates first
166
+ if mcp_server is not None:
167
+ try:
168
+ # Get resource templates from MCP server
169
+ templates = await mcp_server.get_resource_templates()
170
+ if uri in templates:
171
+ template = templates[uri]
172
+ # Call the template's underlying function directly
173
+ # The fn expects the template variables as kwargs
174
+ fn_result = template.fn(**kwargs)
175
+ # Handle both sync and async functions
176
+ if inspect.iscoroutine(fn_result):
177
+ fn_result = await fn_result
178
+ if isinstance(fn_result, str):
179
+ return fn_result
180
+ return json.dumps(fn_result, indent=2)
181
+ except Exception as e:
182
+ logger.warning(f"Failed to resolve resource {uri} from MCP server: {e}")
183
+
184
+ # Fallback: substitute template variables and use load_resource
185
+ resolved_uri = uri
186
+ for var in template_vars:
187
+ if var in kwargs:
188
+ resolved_uri = resolved_uri.replace(f"{{{var}}}", str(kwargs[var]))
189
+ else:
190
+ return json.dumps({"error": f"Missing required parameter: {var}"})
191
+
192
+ from rem.api.mcp_router.resources import load_resource
193
+ result = await load_resource(resolved_uri)
194
+ if isinstance(result, str):
195
+ return result
196
+ return json.dumps(result, indent=2)
197
+
198
+ # Build parameter annotations for Pydantic AI
199
+ wrapper.__name__ = func_name
200
+ wrapper.__doc__ = description
201
+ # Add type hints for parameters
202
+ wrapper.__annotations__ = {var: str for var in template_vars}
203
+ wrapper.__annotations__['return'] = str
204
+
205
+ logger.info(f"Built parameterized resource tool: {func_name} (uri: {uri}, params: {template_vars})")
206
+ else:
207
+ # Concrete URI -> no-param tool
208
+ async def wrapper(**kwargs: Any) -> str:
209
+ """Fetch MCP resource and return contents."""
210
+ import asyncio
211
+ import inspect
212
+
213
+ if kwargs:
214
+ logger.warning(f"Resource tool {func_name} called with unexpected kwargs: {list(kwargs.keys())}")
215
+
216
+ # Try to resolve from MCP server's resources first
217
+ if mcp_server is not None:
218
+ try:
219
+ resources = await mcp_server.get_resources()
220
+ if uri in resources:
221
+ resource = resources[uri]
222
+ # Call the resource's underlying function
223
+ fn_result = resource.fn()
224
+ if inspect.iscoroutine(fn_result):
225
+ fn_result = await fn_result
226
+ if isinstance(fn_result, str):
227
+ return fn_result
228
+ return json.dumps(fn_result, indent=2)
229
+ except Exception as e:
230
+ logger.warning(f"Failed to resolve resource {uri} from MCP server: {e}")
231
+
232
+ # Fallback to load_resource
233
+ from rem.api.mcp_router.resources import load_resource
234
+ result = await load_resource(uri)
235
+ if isinstance(result, str):
236
+ return result
237
+ return json.dumps(result, indent=2)
238
+
239
+ wrapper.__name__ = func_name
240
+ wrapper.__doc__ = description
241
+
242
+ logger.info(f"Built resource tool: {func_name} (uri: {uri})")
243
+
244
+ return Tool(wrapper)
rem/agentic/otel/setup.py CHANGED
@@ -14,6 +14,7 @@ from loguru import logger
14
14
 
15
15
  from ...settings import settings
16
16
 
17
+
17
18
  # Global flag to track if instrumentation is initialized
18
19
  _instrumentation_initialized = False
19
20
 
@@ -52,12 +53,94 @@ def setup_instrumentation() -> None:
52
53
 
53
54
  try:
54
55
  from opentelemetry import trace
55
- from opentelemetry.sdk.trace import TracerProvider
56
- from opentelemetry.sdk.trace.export import BatchSpanProcessor
56
+ from opentelemetry.sdk.trace import TracerProvider, ReadableSpan
57
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult
57
58
  from opentelemetry.sdk.resources import Resource, SERVICE_NAME, DEPLOYMENT_ENVIRONMENT
58
59
  from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HTTPExporter
59
60
  from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCExporter
60
61
 
62
+ class SanitizingSpanExporter(SpanExporter):
63
+ """
64
+ Wrapper exporter that sanitizes span attributes before export.
65
+
66
+ Removes None values that cause OTLP encoding failures like:
67
+ - llm.input_messages.3.message.content: None
68
+ """
69
+
70
+ def __init__(self, wrapped_exporter: SpanExporter):
71
+ self._wrapped = wrapped_exporter
72
+
73
+ def _sanitize_value(self, value):
74
+ """Recursively sanitize a value, replacing None with empty string."""
75
+ if value is None:
76
+ return "" # Replace None with empty string
77
+ if isinstance(value, dict):
78
+ return {k: self._sanitize_value(v) for k, v in value.items()}
79
+ if isinstance(value, (list, tuple)):
80
+ return [self._sanitize_value(v) for v in value]
81
+ return value
82
+
83
+ def export(self, spans: tuple[ReadableSpan, ...]) -> SpanExportResult:
84
+ # Create sanitized copies of spans
85
+ sanitized_spans = []
86
+ for span in spans:
87
+ if span.attributes:
88
+ # Sanitize all attribute values - replace None with empty string
89
+ sanitized_attrs = {}
90
+ for k, v in span.attributes.items():
91
+ sanitized_attrs[k] = self._sanitize_value(v)
92
+ sanitized_spans.append(_SanitizedSpan(span, sanitized_attrs))
93
+ else:
94
+ sanitized_spans.append(span)
95
+
96
+ return self._wrapped.export(tuple(sanitized_spans))
97
+
98
+ def shutdown(self) -> None:
99
+ self._wrapped.shutdown()
100
+
101
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
102
+ return self._wrapped.force_flush(timeout_millis)
103
+
104
+ class _SanitizedSpan(ReadableSpan):
105
+ """ReadableSpan wrapper with sanitized attributes."""
106
+
107
+ def __init__(self, original: ReadableSpan, sanitized_attributes: dict):
108
+ self._original = original
109
+ self._sanitized_attributes = sanitized_attributes
110
+
111
+ @property
112
+ def name(self): return self._original.name
113
+ @property
114
+ def context(self): return self._original.context
115
+ @property
116
+ def parent(self): return self._original.parent
117
+ @property
118
+ def resource(self): return self._original.resource
119
+ @property
120
+ def instrumentation_scope(self): return self._original.instrumentation_scope
121
+ @property
122
+ def status(self): return self._original.status
123
+ @property
124
+ def start_time(self): return self._original.start_time
125
+ @property
126
+ def end_time(self): return self._original.end_time
127
+ @property
128
+ def links(self): return self._original.links
129
+ @property
130
+ def events(self): return self._original.events
131
+ @property
132
+ def kind(self): return self._original.kind
133
+ @property
134
+ def attributes(self): return self._sanitized_attributes
135
+ @property
136
+ def dropped_attributes(self): return self._original.dropped_attributes
137
+ @property
138
+ def dropped_events(self): return self._original.dropped_events
139
+ @property
140
+ def dropped_links(self): return self._original.dropped_links
141
+
142
+ def get_span_context(self): return self._original.get_span_context()
143
+
61
144
  # Create resource with service metadata
62
145
  resource = Resource(
63
146
  attributes={
@@ -72,16 +155,20 @@ def setup_instrumentation() -> None:
72
155
 
73
156
  # Configure OTLP exporter based on protocol
74
157
  if settings.otel.protocol == "grpc":
75
- exporter = GRPCExporter(
158
+ base_exporter = GRPCExporter(
76
159
  endpoint=settings.otel.collector_endpoint,
77
160
  timeout=settings.otel.export_timeout,
161
+ insecure=settings.otel.insecure,
78
162
  )
79
163
  else: # http
80
- exporter = HTTPExporter(
164
+ base_exporter = HTTPExporter(
81
165
  endpoint=f"{settings.otel.collector_endpoint}/v1/traces",
82
166
  timeout=settings.otel.export_timeout,
83
167
  )
84
168
 
169
+ # Wrap with sanitizing exporter to handle None values
170
+ exporter = SanitizingSpanExporter(base_exporter)
171
+
85
172
  # Add span processor
86
173
  tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
87
174
 
@@ -95,6 +182,8 @@ def setup_instrumentation() -> None:
95
182
  # Add OpenInference span processor for Pydantic AI
96
183
  # This adds rich attributes (openinference.span.kind, input/output, etc.) to ALL traces
97
184
  # Phoenix receives these traces via the OTLP collector - no separate "Phoenix integration" needed
185
+ # Note: The OTEL exporter may log warnings about None values in tool call messages,
186
+ # but this is a known limitation in openinference-instrumentation-pydantic-ai
98
187
  try:
99
188
  from openinference.instrumentation.pydantic_ai import OpenInferenceSpanProcessor as PydanticAISpanProcessor
100
189