Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-observability/src/lexigram/ai/observability/tracing/core.py: 31%

113 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +0800

1""" 

2Distributed tracing for Lexigram Intelligence operations. 

3 

4This module provides distributed tracing capabilities for: 

5- LLM API calls with token and cost tracking 

6- Vector store operations (add, search, delete) 

7- RAG pipeline stages (retrieval, ranking, synthesis) 

8- Embedding operations 

9 

10All traces use lexigram-monitor's Tracer for OpenTelemetry compatibility. 

11""" 

12 

13from __future__ import annotations 

14 

15from typing import TYPE_CHECKING, Any 

16 

17from lexigram.contracts.ai.callbacks import CallbackHandlerProtocol 

18from lexigram.contracts.ai.llm import ChatMessage, Completion 

19from lexigram.contracts.core.logging import RedactorProtocol 

20from lexigram.contracts.observability.tracing import SpanProtocol as Span 

21from lexigram.contracts.observability.tracing import TracerProtocol as Tracer 

22from lexigram.di.decorators import inject 

23from lexigram.observability.core import NoOpTracer 

24 

25if TYPE_CHECKING: 

26 from contextlib import AbstractContextManager as ContextManager 

27 

28 

29# TracerProtocol is an alias for Tracer in contracts 

30TracerProtocol = Tracer 

31 

32 

33@inject 

34class AITracer(CallbackHandlerProtocol): 

35 """Distributed tracer for intelligence operations. 

36 

37 Provides span management and context propagation for: 

38 - LLM completions and streaming 

39 - Vector store operations 

40 - RAG pipeline execution 

41 - Embedding generation 

42 

43 Also implements CallbackHandlerProtocol for event-driven tracing. 

44 

45 Example: 

46 >>> tracer = AITracer() 

47 >>> async with tracer.trace_llm_call("openai", "gpt-4") as span: 

48 ... response = await client.complete(messages) 

49 ... span.set_attribute("tokens.total", response.usage.total_tokens) 

50 ... span.set_attribute("cost", response.cost) 

51 """ 

52 

53 def __init__( 

54 self, 

55 tracer: Tracer | None = None, 

56 *, 

57 redaction_policy: RedactorProtocol | None = None, 

58 max_attribute_length: int | None = None, 

59 ) -> None: 

60 """Initialize intelligence tracer. 

61 

62 Args: 

63 tracer: Tracer instance to use for tracing. Defaults to a 

64 no-op tracer when ``None``. 

65 redaction_policy: Optional policy applied to every payload 

66 dict before it reaches a span boundary (``start_span`` 

67 attributes and ``add_event``). Defaults to ``None``, 

68 which passes payloads through unchanged. 

69 max_attribute_length: Optional cap on string attribute 

70 values, applied recursively to dicts, lists, and 

71 tuples, independent of the redaction policy. Defaults 

72 to ``None`` (no truncation). 

73 """ 

74 self.tracer = tracer if tracer is not None else NoOpTracer() 

75 self._redaction_policy = redaction_policy 

76 self._max_attribute_length = max_attribute_length 

77 

78 def trace_llm_call( 

79 self, 

80 provider: str, 

81 model: str, 

82 **attributes: Any, 

83 ) -> ContextManager[Span]: 

84 """Create a span for LLM API call. 

85 

86 Args: 

87 provider: LLM provider name (e.g., "openai", "anthropic") 

88 model: Model name (e.g., "gpt-4", "claude-3-opus") 

89 **attributes: Additional span attributes 

90 

91 Returns: 

92 Span context manager 

93 

94 Example: 

95 >>> tracer = AITracer() 

96 >>> with tracer.trace_llm_call("openai", "gpt-4") as span: 

97 ... response = await client.complete(messages) 

98 ... span.set_attribute("tokens.total", response.usage.total_tokens) 

99 """ 

100 span_attributes = { 

101 "llm.provider": provider, 

102 "llm.model": model, 

103 "operation.type": "llm.completion", 

104 **attributes, 

105 } 

106 return self.tracer.start_span( 

107 name=f"llm.{provider}.{model}", 

108 attributes=span_attributes, 

109 ) 

110 

111 def trace_operation( 

112 self, 

113 name: str, 

114 **attrs: Any, 

115 ) -> ContextManager[Span]: 

116 """Generic operation tracing helper. 

117 

118 This mirrors the `Tracer.trace_operation` API and is used by 

119 worker code that needs a generic operation span (e.g., document 

120 parsing/chunking). 

121 """ 

122 return self.tracer.start_span(name=name, attributes=attrs or {}) 

123 

124 def trace_vector_operation( 

125 self, 

126 operation: str, 

127 provider: str, 

128 collection: str | None = None, 

129 **attributes: Any, 

130 ) -> ContextManager[Span]: 

131 """Create a span for vector store operation. 

132 

133 Args: 

134 operation: Operation type (e.g., "add", "search", "delete") 

135 provider: Vector store provider (e.g., "pgvector", "chroma") 

136 collection: Optional collection/table name 

137 **attributes: Additional span attributes 

138 

139 Returns: 

140 Span context manager 

141 

142 Example: 

143 >>> with tracer.trace_vector_operation("search", "pgvector", "documents") as span: 

144 ... results = await store.search(query, limit=10) 

145 ... span.set_attribute("results.count", len(results)) 

146 """ 

147 span_attributes = { 

148 "vector.operation": operation, 

149 "vector.provider": provider, 

150 "operation.type": "vector.operation", 

151 **attributes, 

152 } 

153 if collection: 

154 span_attributes["vector.collection"] = collection 

155 

156 return self.tracer.start_span( 

157 name=f"vector.{operation}.{provider}", 

158 attributes=span_attributes, 

159 ) 

160 

161 def trace_embedding_operation( 

162 self, 

163 model: str, 

164 batch_size: int | None = None, 

165 **attributes: Any, 

166 ) -> ContextManager[Span]: 

167 """Create a span for embedding generation. 

168 

169 Args: 

170 model: Embedding model name 

171 batch_size: Optional number of texts being embedded 

172 **attributes: Additional span attributes 

173 

174 Returns: 

175 Span context manager 

176 

177 Example: 

178 >>> with tracer.trace_embedding_operation("text-embedding-ada-002", 5) as span: 

179 ... embeddings = await embedder.embed(texts) 

180 ... span.set_attribute("embeddings.dimensions", len(embeddings[0])) 

181 """ 

182 span_attributes = { 

183 "embedding.model": model, 

184 "operation.type": "embedding.generation", 

185 **attributes, 

186 } 

187 if batch_size is not None: 

188 span_attributes["embedding.batch_size"] = batch_size 

189 

190 return self.tracer.start_span( 

191 name=f"embedding.{model}", 

192 attributes=span_attributes, 

193 ) 

194 

195 def trace_rag_stage( 

196 self, 

197 stage: str, 

198 pipeline: str = "default", 

199 **attributes: Any, 

200 ) -> ContextManager[Span]: 

201 """Create a span for RAG pipeline stage. 

202 

203 Args: 

204 stage: Stage name (e.g., "retrieval", "ranking", "synthesis") 

205 pipeline: Pipeline name 

206 **attributes: Additional span attributes 

207 

208 Returns: 

209 Span context manager 

210 

211 Example: 

212 >>> with tracer.trace_rag_stage("retrieval", "default") as span: 

213 ... documents = await retriever.retrieve(query) 

214 ... span.set_attribute("documents.count", len(documents)) 

215 """ 

216 span_attributes = { 

217 "rag.stage": stage, 

218 "rag.pipeline": pipeline, 

219 "operation.type": "rag.stage", 

220 **attributes, 

221 } 

222 

223 return self.tracer.start_span( 

224 name=f"rag.{stage}", 

225 attributes=span_attributes, 

226 ) 

227 

228 def trace_rag_query( 

229 self, 

230 query: str, 

231 pipeline: str = "default", 

232 **attributes: Any, 

233 ) -> ContextManager[Span]: 

234 """Create a span for complete RAG query. 

235 

236 Args: 

237 query: Query text 

238 pipeline: Pipeline name 

239 **attributes: Additional span attributes 

240 

241 Returns: 

242 Span context manager 

243 

244 Example: 

245 >>> with tracer.trace_rag_query("What is Python?") as span: 

246 ... result = await rag_pipeline.query(query) 

247 ... span.set_attribute("answer.length", len(result.answer)) 

248 """ 

249 span_attributes = { 

250 "rag.query": query[:100], # Truncate long queries 

251 "rag.pipeline": pipeline, 

252 "operation.type": "rag.query", 

253 **attributes, 

254 } 

255 

256 return self.tracer.start_span( 

257 name="rag.query", 

258 attributes=span_attributes, 

259 ) 

260 

261 def get_current_span(self) -> Span | None: 

262 """Get the currently active span. 

263 

264 Returns: 

265 Current span or None 

266 """ 

267 return self.tracer.get_current_span() 

268 

269 def _sanitize_attributes(self, attributes: dict[str, Any]) -> dict[str, Any]: 

270 """Redact and/or truncate *attributes* before a span boundary. 

271 

272 When neither ``redaction_policy`` nor ``max_attribute_length`` 

273 is configured, *attributes* is returned unchanged so trace 

274 output stays byte-identical to an unsanitized tracer. 

275 

276 Args: 

277 attributes: The payload dict destined for ``start_span`` 

278 attributes or ``add_event``. 

279 

280 Returns: 

281 The sanitized payload dict. 

282 """ 

283 if self._redaction_policy is None and self._max_attribute_length is None: 

284 return attributes 

285 sanitized = attributes 

286 if self._redaction_policy is not None: 

287 sanitized = self._redaction_policy.redact_dict(sanitized) 

288 if self._max_attribute_length is not None: 

289 sanitized = self._truncate_attributes(sanitized) 

290 return sanitized 

291 

292 def _truncate_attributes(self, attributes: dict[str, Any]) -> dict[str, Any]: 

293 """Cap *attributes* string values at ``max_attribute_length``.""" 

294 return {key: self._truncate_value(value) for key, value in attributes.items()} 

295 

296 def _truncate_value(self, value: Any) -> Any: 

297 """Truncate *value* if it is an oversized string, recursing containers.""" 

298 max_length = self._max_attribute_length 

299 if max_length is None: 

300 return value 

301 if isinstance(value, str): 

302 if len(value) > max_length: 

303 return value[:max_length] 

304 return value 

305 if isinstance(value, dict): 

306 return self._truncate_attributes(value) 

307 if isinstance(value, list): 

308 return [self._truncate_value(item) for item in value] 

309 if isinstance(value, tuple): 

310 return tuple(self._truncate_value(item) for item in value) 

311 return value 

312 

313 async def on_llm_start( 

314 self, 

315 messages: list[ChatMessage], 

316 model: str, 

317 **kwargs: Any, 

318 ) -> None: 

319 """Called when an LLM call starts.""" 

320 span_attributes = { 

321 "llm.model": model, 

322 "operation.type": "llm.start", 

323 **kwargs, 

324 } 

325 self.tracer.start_span(name=f"llm.{model}", attributes=span_attributes) 

326 

327 async def on_llm_new_token( 

328 self, 

329 token: str, 

330 **kwargs: Any, 

331 ) -> None: 

332 """Called for each new token in a streaming LLM response.""" 

333 span = self.tracer.get_current_span() 

334 if span: 

335 span.add_event("llm.token", {"token": token}) 

336 

337 async def on_llm_end( 

338 self, 

339 response: Completion, 

340 **kwargs: Any, 

341 ) -> None: 

342 """Called when an LLM call completes successfully.""" 

343 span = self.tracer.get_current_span() 

344 if span: 

345 span.add_event("llm.end", {"model": response.model}) 

346 span.end() 

347 

348 async def on_llm_error( 

349 self, 

350 error: Exception, 

351 **kwargs: Any, 

352 ) -> None: 

353 """Called when an LLM call fails.""" 

354 span = self.tracer.get_current_span() 

355 if span: 

356 span.add_event("llm.error", {"error": str(error)}) 

357 span.set_status("error") 

358 span.end() 

359 

360 async def on_chain_start( 

361 self, 

362 name: str, 

363 inputs: dict[str, Any], 

364 **kwargs: Any, 

365 ) -> None: 

366 """Called when a chain/pipeline starts executing.""" 

367 self.tracer.start_span( 

368 name=f"chain.{name}", attributes={"chain.name": name, **kwargs} 

369 ) 

370 

371 async def on_chain_end( 

372 self, 

373 name: str, 

374 outputs: dict[str, Any], 

375 **kwargs: Any, 

376 ) -> None: 

377 """Called when a chain/pipeline completes.""" 

378 span = self.tracer.get_current_span() 

379 if span: 

380 span.add_event("chain.end", {"chain.name": name}) 

381 span.end() 

382 

383 async def on_tool_start( 

384 self, 

385 tool_name: str, 

386 arguments: dict[str, Any], 

387 **kwargs: Any, 

388 ) -> None: 

389 """Called when a tool starts executing.""" 

390 self.tracer.start_span( 

391 name=f"tool.{tool_name}", 

392 attributes=self._sanitize_attributes( 

393 {"tool.name": tool_name, "tool.args": arguments, **kwargs} 

394 ), 

395 ) 

396 

397 async def on_tool_end( 

398 self, 

399 tool_name: str, 

400 result: Any, 

401 **kwargs: Any, 

402 ) -> None: 

403 """Called when a tool finishes executing.""" 

404 span = self.tracer.get_current_span() 

405 if span: 

406 span.add_event("tool.end", {"tool.name": tool_name}) 

407 span.end() 

408 

409 async def on_agent_action( 

410 self, 

411 action: dict[str, Any], 

412 **kwargs: Any, 

413 ) -> None: 

414 """Called when an agent takes an action.""" 

415 span = self.tracer.get_current_span() 

416 if span: 

417 span.add_event("agent.action", self._sanitize_attributes(action)) 

418 

419 async def on_agent_finish( 

420 self, 

421 response: dict[str, Any], 

422 **kwargs: Any, 

423 ) -> None: 

424 """Called when an agent finishes executing.""" 

425 span = self.tracer.get_current_span() 

426 if span: 

427 span.add_event("agent.finish", self._sanitize_attributes(response)) 

428 span.end() 

429 

430 async def on_retriever_start( 

431 self, 

432 query: str, 

433 **kwargs: Any, 

434 ) -> None: 

435 """Called when a retriever starts a search.""" 

436 self.tracer.start_span( 

437 name="retriever.search", 

438 attributes=self._sanitize_attributes({"retriever.query": query, **kwargs}), 

439 ) 

440 

441 async def on_retriever_end( 

442 self, 

443 documents: list[Any], 

444 **kwargs: Any, 

445 ) -> None: 

446 """Called when a retriever completes a search.""" 

447 span = self.tracer.get_current_span() 

448 if span: 

449 span.add_event("retriever.end", {"documents.count": len(documents)}) 

450 span.end()