prela 0.1.0__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.
Files changed (71) hide show
  1. prela/__init__.py +394 -0
  2. prela/_version.py +3 -0
  3. prela/contrib/CLI.md +431 -0
  4. prela/contrib/README.md +118 -0
  5. prela/contrib/__init__.py +5 -0
  6. prela/contrib/cli.py +1063 -0
  7. prela/contrib/explorer.py +571 -0
  8. prela/core/__init__.py +64 -0
  9. prela/core/clock.py +98 -0
  10. prela/core/context.py +228 -0
  11. prela/core/replay.py +403 -0
  12. prela/core/sampler.py +178 -0
  13. prela/core/span.py +295 -0
  14. prela/core/tracer.py +498 -0
  15. prela/evals/__init__.py +94 -0
  16. prela/evals/assertions/README.md +484 -0
  17. prela/evals/assertions/__init__.py +78 -0
  18. prela/evals/assertions/base.py +90 -0
  19. prela/evals/assertions/multi_agent.py +625 -0
  20. prela/evals/assertions/semantic.py +223 -0
  21. prela/evals/assertions/structural.py +443 -0
  22. prela/evals/assertions/tool.py +380 -0
  23. prela/evals/case.py +370 -0
  24. prela/evals/n8n/__init__.py +69 -0
  25. prela/evals/n8n/assertions.py +450 -0
  26. prela/evals/n8n/runner.py +497 -0
  27. prela/evals/reporters/README.md +184 -0
  28. prela/evals/reporters/__init__.py +32 -0
  29. prela/evals/reporters/console.py +251 -0
  30. prela/evals/reporters/json.py +176 -0
  31. prela/evals/reporters/junit.py +278 -0
  32. prela/evals/runner.py +525 -0
  33. prela/evals/suite.py +316 -0
  34. prela/exporters/__init__.py +27 -0
  35. prela/exporters/base.py +189 -0
  36. prela/exporters/console.py +443 -0
  37. prela/exporters/file.py +322 -0
  38. prela/exporters/http.py +394 -0
  39. prela/exporters/multi.py +154 -0
  40. prela/exporters/otlp.py +388 -0
  41. prela/instrumentation/ANTHROPIC.md +297 -0
  42. prela/instrumentation/LANGCHAIN.md +480 -0
  43. prela/instrumentation/OPENAI.md +59 -0
  44. prela/instrumentation/__init__.py +49 -0
  45. prela/instrumentation/anthropic.py +1436 -0
  46. prela/instrumentation/auto.py +129 -0
  47. prela/instrumentation/base.py +436 -0
  48. prela/instrumentation/langchain.py +959 -0
  49. prela/instrumentation/llamaindex.py +719 -0
  50. prela/instrumentation/multi_agent/__init__.py +48 -0
  51. prela/instrumentation/multi_agent/autogen.py +357 -0
  52. prela/instrumentation/multi_agent/crewai.py +404 -0
  53. prela/instrumentation/multi_agent/langgraph.py +299 -0
  54. prela/instrumentation/multi_agent/models.py +203 -0
  55. prela/instrumentation/multi_agent/swarm.py +231 -0
  56. prela/instrumentation/n8n/__init__.py +68 -0
  57. prela/instrumentation/n8n/code_node.py +534 -0
  58. prela/instrumentation/n8n/models.py +336 -0
  59. prela/instrumentation/n8n/webhook.py +489 -0
  60. prela/instrumentation/openai.py +1198 -0
  61. prela/license.py +245 -0
  62. prela/replay/__init__.py +31 -0
  63. prela/replay/comparison.py +390 -0
  64. prela/replay/engine.py +1227 -0
  65. prela/replay/loader.py +231 -0
  66. prela/replay/result.py +196 -0
  67. prela-0.1.0.dist-info/METADATA +399 -0
  68. prela-0.1.0.dist-info/RECORD +71 -0
  69. prela-0.1.0.dist-info/WHEEL +4 -0
  70. prela-0.1.0.dist-info/entry_points.txt +2 -0
  71. prela-0.1.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,534 @@
1
+ """
2
+ Helper utilities for tracing Python code executed within n8n Code nodes.
3
+
4
+ This module provides context managers and decorators that enable users to
5
+ instrument custom Python code running inside n8n Code nodes, creating
6
+ properly nested spans that integrate with the n8n workflow execution.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import logging
13
+ from typing import Any, Callable, Optional
14
+
15
+ from prela.core.clock import now
16
+ from prela.core.span import Span, SpanStatus, SpanType
17
+ from prela.core.tracer import Tracer, get_tracer
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class PrelaN8nContext:
23
+ """
24
+ Context manager for tracing custom Python code within n8n Code nodes.
25
+
26
+ This class creates a workflow-level span and node-level span that properly
27
+ integrate with Prela's tracing infrastructure. It provides helper methods
28
+ for logging LLM calls, tool calls, and retrieval operations.
29
+
30
+ Example:
31
+ ```python
32
+ # Inside n8n Code node
33
+ from prela.instrumentation.n8n import PrelaN8nContext
34
+
35
+ ctx = PrelaN8nContext(
36
+ workflow_id=$workflow.id,
37
+ workflow_name=$workflow.name,
38
+ execution_id=$execution.id,
39
+ node_name=$node.name
40
+ )
41
+
42
+ with ctx:
43
+ # Your custom code
44
+ response = call_my_llm(prompt)
45
+ ctx.log_llm_call(
46
+ model="gpt-4",
47
+ prompt=prompt,
48
+ response=response,
49
+ tokens={"prompt": 100, "completion": 50}
50
+ )
51
+
52
+ return [{"json": {"result": response}}]
53
+ ```
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ workflow_id: str,
59
+ workflow_name: str,
60
+ execution_id: str,
61
+ node_name: str,
62
+ node_type: str = "n8n-nodes-base.code",
63
+ tracer: Optional[Tracer] = None,
64
+ api_key: Optional[str] = None,
65
+ endpoint: Optional[str] = None,
66
+ ):
67
+ """
68
+ Initialize n8n Code node tracing context.
69
+
70
+ Args:
71
+ workflow_id: Unique workflow identifier
72
+ workflow_name: Human-readable workflow name
73
+ execution_id: Unique execution identifier
74
+ node_name: Name of the Code node
75
+ node_type: Node type identifier (default: n8n-nodes-base.code)
76
+ tracer: Prela tracer instance (defaults to global tracer)
77
+ api_key: Optional API key for remote export
78
+ endpoint: Optional endpoint URL for remote export
79
+ """
80
+ self.workflow_id = workflow_id
81
+ self.workflow_name = workflow_name
82
+ self.execution_id = execution_id
83
+ self.node_name = node_name
84
+ self.node_type = node_type
85
+ self.api_key = api_key
86
+ self.endpoint = endpoint
87
+
88
+ # Get or initialize tracer
89
+ if tracer is None:
90
+ self.tracer = get_tracer()
91
+ if self.tracer is None:
92
+ # Initialize default tracer if none exists
93
+ import prela
94
+
95
+ self.tracer = prela.init(
96
+ service_name="n8n-code-node",
97
+ exporter="console" if not endpoint else "http",
98
+ http_endpoint=endpoint,
99
+ api_key=api_key,
100
+ )
101
+ else:
102
+ self.tracer = tracer
103
+
104
+ # Generate trace_id from execution_id
105
+ self.trace_id = f"n8n-{execution_id}"
106
+
107
+ # Spans
108
+ self.workflow_span: Optional[Span] = None
109
+ self.node_span: Optional[Span] = None
110
+
111
+ def __enter__(self) -> "PrelaN8nContext":
112
+ """Start tracing context."""
113
+ try:
114
+ # Create workflow-level span
115
+ self.workflow_span = Span(
116
+ trace_id=self.trace_id,
117
+ parent_span_id=None,
118
+ name=f"n8n.workflow.{self.workflow_name}",
119
+ span_type=SpanType.AGENT,
120
+ started_at=now(),
121
+ attributes={
122
+ "n8n.workflow_id": self.workflow_id,
123
+ "n8n.workflow_name": self.workflow_name,
124
+ "n8n.execution_id": self.execution_id,
125
+ "service.name": "n8n",
126
+ },
127
+ )
128
+
129
+ # Create node-level span
130
+ self.node_span = Span(
131
+ trace_id=self.trace_id,
132
+ parent_span_id=self.workflow_span.span_id,
133
+ name=f"n8n.node.{self.node_name}",
134
+ span_type=SpanType.CUSTOM,
135
+ started_at=now(),
136
+ attributes={
137
+ "n8n.node_name": self.node_name,
138
+ "n8n.node_type": self.node_type,
139
+ "service.name": "n8n",
140
+ },
141
+ )
142
+
143
+ logger.debug(
144
+ f"Started n8n Code node tracing: {self.workflow_name}/{self.node_name}"
145
+ )
146
+
147
+ except Exception as e:
148
+ logger.error(f"Failed to start n8n tracing context: {e}", exc_info=True)
149
+
150
+ return self
151
+
152
+ def __exit__(self, exc_type, exc_val, exc_tb):
153
+ """End tracing context and export spans."""
154
+ try:
155
+ # Handle exceptions
156
+ if exc_type is not None:
157
+ if self.node_span:
158
+ self.node_span.set_status(SpanStatus.ERROR)
159
+ self.node_span.add_event(
160
+ name="exception",
161
+ attributes={
162
+ "exception.type": exc_type.__name__,
163
+ "exception.message": str(exc_val),
164
+ },
165
+ )
166
+ if self.workflow_span:
167
+ self.workflow_span.set_status(SpanStatus.ERROR)
168
+
169
+ # End spans
170
+ if self.node_span:
171
+ self.node_span.end()
172
+ if self.workflow_span:
173
+ self.workflow_span.end()
174
+
175
+ # Export spans if tracer has exporter
176
+ if self.tracer and self.tracer.exporter:
177
+ spans = [s for s in [self.workflow_span, self.node_span] if s]
178
+ for span in spans:
179
+ self.tracer.exporter.export([span])
180
+
181
+ logger.debug(f"Completed n8n Code node tracing: {self.node_name}")
182
+
183
+ except Exception as e:
184
+ logger.error(f"Failed to end n8n tracing context: {e}", exc_info=True)
185
+
186
+ # Don't suppress exceptions
187
+ return False
188
+
189
+ def log_llm_call(
190
+ self,
191
+ model: str,
192
+ prompt: str,
193
+ response: str,
194
+ tokens: Optional[dict] = None,
195
+ provider: Optional[str] = None,
196
+ temperature: Optional[float] = None,
197
+ **kwargs,
198
+ ) -> None:
199
+ """
200
+ Log an LLM call within the Code node.
201
+
202
+ Args:
203
+ model: Model identifier (e.g., "gpt-4", "claude-3-opus")
204
+ prompt: Input prompt text
205
+ response: Model response text
206
+ tokens: Token usage dict with "prompt", "completion", "total" keys
207
+ provider: AI provider (openai, anthropic, etc.)
208
+ temperature: Temperature parameter used
209
+ **kwargs: Additional attributes to attach to the span
210
+ """
211
+ if not self.node_span:
212
+ logger.warning("Cannot log LLM call: node span not initialized")
213
+ return
214
+
215
+ try:
216
+ # NEW: Replay capture if enabled
217
+ replay_capture = None
218
+ if self.tracer and getattr(self.tracer, "capture_for_replay", False):
219
+ from prela.core.replay import ReplayCapture
220
+
221
+ replay_capture = ReplayCapture()
222
+ replay_capture.set_llm_request(
223
+ model=model,
224
+ prompt=prompt,
225
+ temperature=temperature,
226
+ )
227
+ replay_capture.set_llm_response(
228
+ text=response,
229
+ prompt_tokens=tokens.get("prompt") if tokens else None,
230
+ completion_tokens=tokens.get("completion") if tokens else None,
231
+ )
232
+ if provider:
233
+ replay_capture.set_model_info(provider=provider)
234
+
235
+ # Create child LLM span
236
+ llm_span = Span(
237
+ trace_id=self.trace_id,
238
+ parent_span_id=self.node_span.span_id,
239
+ name=f"llm.{model}",
240
+ span_type=SpanType.LLM,
241
+ started_at=now(),
242
+ attributes={
243
+ "llm.model": model,
244
+ "llm.prompt": prompt[:500], # Truncate
245
+ "llm.response": response[:500], # Truncate
246
+ **kwargs,
247
+ },
248
+ )
249
+
250
+ # Add provider if specified
251
+ if provider:
252
+ llm_span.attributes["llm.provider"] = provider
253
+
254
+ # Add temperature if specified
255
+ if temperature is not None:
256
+ llm_span.attributes["llm.temperature"] = temperature
257
+
258
+ # Add token usage if available
259
+ if tokens:
260
+ if "prompt" in tokens:
261
+ llm_span.attributes["llm.prompt_tokens"] = tokens["prompt"]
262
+ if "completion" in tokens:
263
+ llm_span.attributes["llm.completion_tokens"] = tokens["completion"]
264
+ if "total" in tokens:
265
+ llm_span.attributes["llm.total_tokens"] = tokens["total"]
266
+
267
+ # NEW: Attach replay snapshot
268
+ if replay_capture:
269
+ try:
270
+ object.__setattr__(llm_span, "replay_snapshot", replay_capture.build())
271
+ except Exception as e:
272
+ logger.debug(f"Failed to capture replay data: {e}")
273
+
274
+ # End span immediately (synchronous call)
275
+ llm_span.end()
276
+
277
+ # Export if tracer has exporter
278
+ if self.tracer and self.tracer.exporter:
279
+ self.tracer.exporter.export([llm_span])
280
+
281
+ logger.debug(f"Logged LLM call: {model}")
282
+
283
+ except Exception as e:
284
+ logger.error(f"Failed to log LLM call: {e}", exc_info=True)
285
+
286
+ def log_tool_call(
287
+ self,
288
+ tool_name: str,
289
+ input: Any,
290
+ output: Any,
291
+ error: Optional[str] = None,
292
+ **kwargs,
293
+ ) -> None:
294
+ """
295
+ Log a tool call within the Code node.
296
+
297
+ Args:
298
+ tool_name: Name of the tool/function called
299
+ input: Input parameters to the tool
300
+ output: Output result from the tool
301
+ error: Optional error message if tool failed
302
+ **kwargs: Additional attributes to attach to the span
303
+ """
304
+ if not self.node_span:
305
+ logger.warning("Cannot log tool call: node span not initialized")
306
+ return
307
+
308
+ try:
309
+ # Create child tool span
310
+ tool_span = Span(
311
+ trace_id=self.trace_id,
312
+ parent_span_id=self.node_span.span_id,
313
+ name=f"tool.{tool_name}",
314
+ span_type=SpanType.TOOL,
315
+ started_at=now(),
316
+ attributes={
317
+ "tool.name": tool_name,
318
+ "tool.input": str(input)[:500], # Truncate
319
+ "tool.output": str(output)[:500], # Truncate
320
+ **kwargs,
321
+ },
322
+ )
323
+
324
+ # Add error if present
325
+ if error:
326
+ tool_span.set_status(SpanStatus.ERROR)
327
+ tool_span.attributes["tool.error"] = str(error)[:500]
328
+
329
+ # End span immediately
330
+ tool_span.end()
331
+
332
+ # Export if tracer has exporter
333
+ if self.tracer and self.tracer.exporter:
334
+ self.tracer.exporter.export([tool_span])
335
+
336
+ logger.debug(f"Logged tool call: {tool_name}")
337
+
338
+ except Exception as e:
339
+ logger.error(f"Failed to log tool call: {e}", exc_info=True)
340
+
341
+ def log_retrieval(
342
+ self,
343
+ query: str,
344
+ documents: list[dict],
345
+ retriever_type: Optional[str] = None,
346
+ similarity_top_k: Optional[int] = None,
347
+ **kwargs,
348
+ ) -> None:
349
+ """
350
+ Log a retrieval/search operation within the Code node.
351
+
352
+ Args:
353
+ query: Search query text
354
+ documents: Retrieved documents (list of dicts with text/score/metadata)
355
+ retriever_type: Type of retriever used (vector, keyword, hybrid)
356
+ similarity_top_k: Number of documents requested
357
+ **kwargs: Additional attributes to attach to the span
358
+ """
359
+ if not self.node_span:
360
+ logger.warning("Cannot log retrieval: node span not initialized")
361
+ return
362
+
363
+ try:
364
+ # Create child retrieval span
365
+ retrieval_span = Span(
366
+ trace_id=self.trace_id,
367
+ parent_span_id=self.node_span.span_id,
368
+ name="retrieval",
369
+ span_type=SpanType.RETRIEVAL,
370
+ started_at=now(),
371
+ attributes={
372
+ "retrieval.query": query[:200], # Truncate
373
+ "retrieval.document_count": len(documents),
374
+ **kwargs,
375
+ },
376
+ )
377
+
378
+ # Add retriever type if specified
379
+ if retriever_type:
380
+ retrieval_span.attributes["retrieval.type"] = retriever_type
381
+
382
+ # Add similarity_top_k if specified
383
+ if similarity_top_k is not None:
384
+ retrieval_span.attributes["retrieval.similarity_top_k"] = (
385
+ similarity_top_k
386
+ )
387
+
388
+ # Add document details (limit to 5 docs)
389
+ for i, doc in enumerate(documents[:5]):
390
+ if "score" in doc:
391
+ retrieval_span.attributes[f"retrieval.document.{i}.score"] = doc[
392
+ "score"
393
+ ]
394
+ if "text" in doc:
395
+ retrieval_span.attributes[f"retrieval.document.{i}.text"] = str(
396
+ doc["text"]
397
+ )[:200]
398
+
399
+ # End span immediately
400
+ retrieval_span.end()
401
+
402
+ # Export if tracer has exporter
403
+ if self.tracer and self.tracer.exporter:
404
+ self.tracer.exporter.export([retrieval_span])
405
+
406
+ logger.debug(f"Logged retrieval: {len(documents)} documents")
407
+
408
+ except Exception as e:
409
+ logger.error(f"Failed to log retrieval: {e}", exc_info=True)
410
+
411
+
412
+ def trace_n8n_code(
413
+ items: list,
414
+ workflow_context: dict,
415
+ execution_context: dict,
416
+ node_context: dict,
417
+ tracer: Optional[Tracer] = None,
418
+ api_key: Optional[str] = None,
419
+ endpoint: Optional[str] = None,
420
+ ) -> PrelaN8nContext:
421
+ """
422
+ Convenience function for tracing n8n Code node execution.
423
+
424
+ This function extracts the necessary identifiers from n8n's built-in
425
+ context variables ($workflow, $execution, $node) and creates a
426
+ PrelaN8nContext for easy tracing.
427
+
428
+ Args:
429
+ items: n8n input items (from previous node)
430
+ workflow_context: $workflow context from n8n
431
+ execution_context: $execution context from n8n
432
+ node_context: $node context from n8n
433
+ tracer: Optional Prela tracer instance
434
+ api_key: Optional API key for remote export
435
+ endpoint: Optional endpoint URL for remote export
436
+
437
+ Returns:
438
+ PrelaN8nContext ready to use as context manager
439
+
440
+ Example:
441
+ ```python
442
+ # Inside n8n Code node
443
+ from prela.instrumentation.n8n import trace_n8n_code
444
+
445
+ with trace_n8n_code(items, $workflow, $execution, $node) as ctx:
446
+ # Your code here
447
+ result = my_function(items[0]["json"])
448
+
449
+ # Log LLM call
450
+ ctx.log_llm_call(
451
+ model="gpt-4",
452
+ prompt="Hello",
453
+ response=result,
454
+ tokens={"prompt": 10, "completion": 20}
455
+ )
456
+
457
+ return [{"json": {"result": result}}]
458
+ ```
459
+ """
460
+ return PrelaN8nContext(
461
+ workflow_id=workflow_context.get("id", "unknown"),
462
+ workflow_name=workflow_context.get("name", "Unknown Workflow"),
463
+ execution_id=execution_context.get("id", "unknown"),
464
+ node_name=node_context.get("name", "Unknown Node"),
465
+ node_type=node_context.get("type", "n8n-nodes-base.code"),
466
+ tracer=tracer,
467
+ api_key=api_key,
468
+ endpoint=endpoint,
469
+ )
470
+
471
+
472
+ def prela_n8n_traced(
473
+ func: Optional[Callable] = None,
474
+ *,
475
+ tracer: Optional[Tracer] = None,
476
+ api_key: Optional[str] = None,
477
+ endpoint: Optional[str] = None,
478
+ ) -> Callable:
479
+ """
480
+ Decorator for automatically tracing n8n Code node functions.
481
+
482
+ This decorator expects the decorated function to accept n8n context
483
+ variables as arguments and automatically wraps the execution in a
484
+ PrelaN8nContext.
485
+
486
+ Args:
487
+ func: Function to decorate (optional, for @prela_n8n_traced usage)
488
+ tracer: Optional Prela tracer instance
489
+ api_key: Optional API key for remote export
490
+ endpoint: Optional endpoint URL for remote export
491
+
492
+ Returns:
493
+ Decorated function
494
+
495
+ Example:
496
+ ```python
497
+ # Inside n8n Code node
498
+ from prela.instrumentation.n8n import prela_n8n_traced
499
+
500
+ @prela_n8n_traced
501
+ def process_items(items, workflow, execution, node):
502
+ # Automatically traced!
503
+ result = call_api(items[0]["json"])
504
+ return [{"json": {"result": result}}]
505
+
506
+ # Call the function with n8n contexts
507
+ return process_items(items, $workflow, $execution, $node)
508
+ ```
509
+ """
510
+
511
+ def decorator(f: Callable) -> Callable:
512
+ @functools.wraps(f)
513
+ def wrapper(items, workflow, execution, node, *args, **kwargs):
514
+ ctx = trace_n8n_code(
515
+ items=items,
516
+ workflow_context=workflow,
517
+ execution_context=execution,
518
+ node_context=node,
519
+ tracer=tracer,
520
+ api_key=api_key,
521
+ endpoint=endpoint,
522
+ )
523
+
524
+ with ctx:
525
+ result = f(items, workflow, execution, node, *args, **kwargs)
526
+ return result
527
+
528
+ return wrapper
529
+
530
+ # Support both @prela_n8n_traced and @prela_n8n_traced()
531
+ if func is None:
532
+ return decorator
533
+ else:
534
+ return decorator(func)