trace-agent-server 0.0.3__tar.gz → 0.2.0__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 (49) hide show
  1. trace_agent_server-0.2.0/PKG-INFO +118 -0
  2. trace_agent_server-0.2.0/README.md +78 -0
  3. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/pyproject.toml +6 -2
  4. trace_agent_server-0.2.0/src/trace_agent_server/api/otlp.py +50 -0
  5. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/api/runs.py +17 -125
  6. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/config.py +2 -0
  7. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/main.py +2 -0
  8. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/schemas.py +1 -0
  9. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/compare.py +3 -2
  10. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/events.py +3 -1
  11. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/explanation.py +9 -2
  12. trace_agent_server-0.2.0/src/trace_agent_server/services/otlp.py +444 -0
  13. trace_agent_server-0.2.0/src/trace_agent_server/services/otlp_decode.py +213 -0
  14. trace_agent_server-0.2.0/src/trace_agent_server/services/run_lifecycle.py +215 -0
  15. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/run_summary.py +28 -4
  16. trace_agent_server-0.2.0/src/trace_agent_server.egg-info/PKG-INFO +118 -0
  17. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server.egg-info/SOURCES.txt +8 -0
  18. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server.egg-info/requires.txt +5 -1
  19. trace_agent_server-0.2.0/tests/test_framework_observations.py +287 -0
  20. trace_agent_server-0.2.0/tests/test_otlp_decode.py +205 -0
  21. trace_agent_server-0.2.0/tests/test_otlp_ingest.py +497 -0
  22. trace_agent_server-0.2.0/tests/test_run_lifecycle.py +117 -0
  23. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/tests/test_sse.py +4 -6
  24. trace_agent_server-0.0.3/PKG-INFO +0 -76
  25. trace_agent_server-0.0.3/README.md +0 -39
  26. trace_agent_server-0.0.3/src/trace_agent_server.egg-info/PKG-INFO +0 -76
  27. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/setup.cfg +0 -0
  28. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/__init__.py +0 -0
  29. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/api/__init__.py +0 -0
  30. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/cli.py +0 -0
  31. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/db.py +0 -0
  32. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/models.py +0 -0
  33. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/__init__.py +0 -0
  34. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/analytics.py +0 -0
  35. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/audit_guardrails.py +0 -0
  36. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/dashboard.py +0 -0
  37. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/graph.py +0 -0
  38. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/observability.py +0 -0
  39. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server/services/provider.py +0 -0
  40. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server.egg-info/dependency_links.txt +0 -0
  41. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server.egg-info/entry_points.txt +0 -0
  42. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/src/trace_agent_server.egg-info/top_level.txt +0 -0
  43. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/tests/test_audit_guardrails_api.py +0 -0
  44. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/tests/test_dashboard.py +0 -0
  45. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/tests/test_events.py +0 -0
  46. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/tests/test_providers.py +0 -0
  47. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/tests/test_runs_api.py +0 -0
  48. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/tests/test_sdk_wrappers.py +0 -0
  49. {trace_agent_server-0.0.3 → trace_agent_server-0.2.0}/tests/test_turns.py +0 -0
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: trace-agent-server
3
+ Version: 0.2.0
4
+ Summary: TraceAgent FastAPI backend server.
5
+ Author-email: Enrique Javier Villar Cea <villarcea.enriquejavier@gmail.com>, Javier Rodriguez Castellano <javirodriguezcastellano@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/LixusSoftware/TraceAgent
8
+ Project-URL: Repository, https://github.com/LixusSoftware/TraceAgent
9
+ Project-URL: Issues, https://github.com/LixusSoftware/TraceAgent/issues
10
+ Keywords: agent,observability,tracing,llm,tools
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: trace-agent-sdk
22
+ Requires-Dist: fastapi<1.0.0,>=0.115.0
23
+ Requires-Dist: httpx<1.0.0,>=0.27.0
24
+ Requires-Dist: openai<4.0.0,>=1.52.0
25
+ Requires-Dist: opentelemetry-proto<2.0.0,>=1.28.0
26
+ Requires-Dist: pydantic<3.0.0,>=2.8.0
27
+ Requires-Dist: pydantic-settings<3.0.0,>=2.3.0
28
+ Requires-Dist: sqlalchemy<3.0.0,>=2.0.32
29
+ Requires-Dist: uvicorn[standard]<1.0.0,>=0.30.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest<9.0.0,>=8.3.2; extra == "dev"
32
+ Requires-Dist: pytest-cov<7.0.0,>=6.0.0; extra == "dev"
33
+ Provides-Extra: providers
34
+ Requires-Dist: anthropic>=0.42.0; extra == "providers"
35
+ Requires-Dist: google-generativeai>=0.8.0; extra == "providers"
36
+ Provides-Extra: langchain
37
+ Requires-Dist: trace-agent-langchain; extra == "langchain"
38
+ Provides-Extra: pydantic-ai
39
+ Requires-Dist: trace-agent-pydantic-ai; extra == "pydantic-ai"
40
+
41
+ # TraceAgent Server
42
+
43
+ ![GitHub Release](https://img.shields.io/github/v/release/LixusSoftware/TraceAgent)
44
+ ![GitHub Stars](https://img.shields.io/github/stars/LixusSoftware/TraceAgent)
45
+ ![GitHub License](https://img.shields.io/github/license/LixusSoftware/TraceAgent)
46
+ ![Tests](https://github.com/LixusSoftware/TraceAgent/actions/workflows/test.yml/badge.svg)
47
+ ![Docker Build](https://github.com/LixusSoftware/TraceAgent/actions/workflows/build-docker.yml/badge.svg)
48
+ [![Website](https://img.shields.io/badge/website-traceagent.vercel.app-blue)](https://traceagent.vercel.app)
49
+
50
+ FastAPI backend for TraceAgent. Stores runs, generates timelines, execution graphs, and explanations.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install trace-agent-server[providers]
56
+ ```
57
+
58
+ ## Run
59
+
60
+ ```bash
61
+ # With the included CLI
62
+ trace-agent-server --host 0.0.0.0 --port 8000
63
+
64
+ # Or directly with uvicorn
65
+ uvicorn trace_agent_server.main:app --reload
66
+ ```
67
+
68
+ ## Tracing agents through OpenTelemetry
69
+
70
+ The server is also an OTLP/HTTP receiver: an agent that already emits OpenTelemetry spans following the
71
+ [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) (Pydantic AI does) is
72
+ traced by pointing its exporter at `POST /v1/traces`, with nothing from TraceAgent installed in the agent.
73
+
74
+ ```python
75
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
76
+ from opentelemetry.sdk.trace import TracerProvider
77
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
78
+ from pydantic_ai import Agent
79
+ from pydantic_ai.capabilities import Instrumentation
80
+ from pydantic_ai.models.instrumented import InstrumentationSettings
81
+
82
+ provider = TracerProvider()
83
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:8000/v1/traces")))
84
+
85
+ agent = Agent("openai:gpt-4o", capabilities=[Instrumentation(settings=InstrumentationSettings(tracer_provider=provider))])
86
+ agent.run_sync("hello")
87
+ provider.shutdown() # a short script must flush before exiting
88
+ ```
89
+
90
+ - One run is created per trace (the trace id is the run id), named after `gen_ai.agent.name`. Add a `trace_agent.run_id`
91
+ resource attribute to attach the spans to a run you created with the SDK instead.
92
+ - `invoke_agent` spans become chain steps, `chat` spans model calls and `execute_tool` spans tools. Event times and
93
+ durations come from the spans; the run gets its provider, model, token totals and tool counters. Other spans are ignored.
94
+ - The run ends when its last agent span ends, also when the agent runs under a web server span or calls other agents.
95
+ - An agent whose output goes through an output function (`output_type=my_function`) also records that function as a tool
96
+ (`final_result` by default), because Pydantic AI emits an `execute_tool` span for it. It counts in the run's tool total
97
+ and shows up as a tool choice. The framework capability only records the agent's regular tools, so the two routes differ
98
+ by that one tool for such agents. Structured output (`output_type=SomeModel`) is not affected.
99
+ - Prompts and outputs are summarized. Full payloads are stored only if `TRACE_AGENT_CAPTURE_FULL_PAYLOADS` is on.
100
+ - Protobuf and JSON bodies are accepted, gzip included. Use either this or the framework integrations for an agent, not
101
+ both, or its run is recorded twice.
102
+
103
+ ## Environment variables
104
+
105
+ | Variable | Default | Description |
106
+ |----------|---------|-------------|
107
+ | `TRACE_AGENT_DATABASE_URL` | `sqlite:///./trace_agent.db` | Database URL (SQLite or PostgreSQL) |
108
+ | `TRACE_AGENT_OTLP_ENABLED` | `true` | Accept OpenTelemetry traces on `POST /v1/traces` |
109
+ | `TRACE_AGENT_OTLP_MAX_BODY_BYTES` | `10000000` | Largest accepted OTLP request, after decompression |
110
+ | `TRACE_AGENT_CAPTURE_FULL_PAYLOADS` | `true` | Keep full payloads, not only summaries |
111
+ | `TRACE_AGENT_OPENAI_API_KEY` | - | OpenAI API key |
112
+ | `TRACE_AGENT_OPENAI_BASE_URL` | - | OpenAI-compatible base URL (e.g. LM Studio) |
113
+ | `TRACE_AGENT_AUDIT_ENABLE_GUARDRAILS` | `false` | Enable prompt injection and PII checks |
114
+ | `TRACE_AGENT_AUDIT_METRICS_ENABLED` | `true` | Expose Prometheus `/metrics` endpoint |
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,78 @@
1
+ # TraceAgent Server
2
+
3
+ ![GitHub Release](https://img.shields.io/github/v/release/LixusSoftware/TraceAgent)
4
+ ![GitHub Stars](https://img.shields.io/github/stars/LixusSoftware/TraceAgent)
5
+ ![GitHub License](https://img.shields.io/github/license/LixusSoftware/TraceAgent)
6
+ ![Tests](https://github.com/LixusSoftware/TraceAgent/actions/workflows/test.yml/badge.svg)
7
+ ![Docker Build](https://github.com/LixusSoftware/TraceAgent/actions/workflows/build-docker.yml/badge.svg)
8
+ [![Website](https://img.shields.io/badge/website-traceagent.vercel.app-blue)](https://traceagent.vercel.app)
9
+
10
+ FastAPI backend for TraceAgent. Stores runs, generates timelines, execution graphs, and explanations.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install trace-agent-server[providers]
16
+ ```
17
+
18
+ ## Run
19
+
20
+ ```bash
21
+ # With the included CLI
22
+ trace-agent-server --host 0.0.0.0 --port 8000
23
+
24
+ # Or directly with uvicorn
25
+ uvicorn trace_agent_server.main:app --reload
26
+ ```
27
+
28
+ ## Tracing agents through OpenTelemetry
29
+
30
+ The server is also an OTLP/HTTP receiver: an agent that already emits OpenTelemetry spans following the
31
+ [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) (Pydantic AI does) is
32
+ traced by pointing its exporter at `POST /v1/traces`, with nothing from TraceAgent installed in the agent.
33
+
34
+ ```python
35
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
36
+ from opentelemetry.sdk.trace import TracerProvider
37
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
38
+ from pydantic_ai import Agent
39
+ from pydantic_ai.capabilities import Instrumentation
40
+ from pydantic_ai.models.instrumented import InstrumentationSettings
41
+
42
+ provider = TracerProvider()
43
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:8000/v1/traces")))
44
+
45
+ agent = Agent("openai:gpt-4o", capabilities=[Instrumentation(settings=InstrumentationSettings(tracer_provider=provider))])
46
+ agent.run_sync("hello")
47
+ provider.shutdown() # a short script must flush before exiting
48
+ ```
49
+
50
+ - One run is created per trace (the trace id is the run id), named after `gen_ai.agent.name`. Add a `trace_agent.run_id`
51
+ resource attribute to attach the spans to a run you created with the SDK instead.
52
+ - `invoke_agent` spans become chain steps, `chat` spans model calls and `execute_tool` spans tools. Event times and
53
+ durations come from the spans; the run gets its provider, model, token totals and tool counters. Other spans are ignored.
54
+ - The run ends when its last agent span ends, also when the agent runs under a web server span or calls other agents.
55
+ - An agent whose output goes through an output function (`output_type=my_function`) also records that function as a tool
56
+ (`final_result` by default), because Pydantic AI emits an `execute_tool` span for it. It counts in the run's tool total
57
+ and shows up as a tool choice. The framework capability only records the agent's regular tools, so the two routes differ
58
+ by that one tool for such agents. Structured output (`output_type=SomeModel`) is not affected.
59
+ - Prompts and outputs are summarized. Full payloads are stored only if `TRACE_AGENT_CAPTURE_FULL_PAYLOADS` is on.
60
+ - Protobuf and JSON bodies are accepted, gzip included. Use either this or the framework integrations for an agent, not
61
+ both, or its run is recorded twice.
62
+
63
+ ## Environment variables
64
+
65
+ | Variable | Default | Description |
66
+ |----------|---------|-------------|
67
+ | `TRACE_AGENT_DATABASE_URL` | `sqlite:///./trace_agent.db` | Database URL (SQLite or PostgreSQL) |
68
+ | `TRACE_AGENT_OTLP_ENABLED` | `true` | Accept OpenTelemetry traces on `POST /v1/traces` |
69
+ | `TRACE_AGENT_OTLP_MAX_BODY_BYTES` | `10000000` | Largest accepted OTLP request, after decompression |
70
+ | `TRACE_AGENT_CAPTURE_FULL_PAYLOADS` | `true` | Keep full payloads, not only summaries |
71
+ | `TRACE_AGENT_OPENAI_API_KEY` | - | OpenAI API key |
72
+ | `TRACE_AGENT_OPENAI_BASE_URL` | - | OpenAI-compatible base URL (e.g. LM Studio) |
73
+ | `TRACE_AGENT_AUDIT_ENABLE_GUARDRAILS` | `false` | Enable prompt injection and PII checks |
74
+ | `TRACE_AGENT_AUDIT_METRICS_ENABLED` | `true` | Expose Prometheus `/metrics` endpoint |
75
+
76
+ ## License
77
+
78
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trace-agent-server"
7
- version = "0.0.3"
7
+ version = "0.2.0"
8
8
  description = "TraceAgent FastAPI backend server."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -29,7 +29,8 @@ dependencies = [
29
29
  "trace-agent-sdk",
30
30
  "fastapi>=0.115.0,<1.0.0",
31
31
  "httpx>=0.27.0,<1.0.0",
32
- "openai>=1.52.0,<2.0.0",
32
+ "openai>=1.52.0,<4.0.0",
33
+ "opentelemetry-proto>=1.28.0,<2.0.0",
33
34
  "pydantic>=2.8.0,<3.0.0",
34
35
  "pydantic-settings>=2.3.0,<3.0.0",
35
36
  "sqlalchemy>=2.0.32,<3.0.0",
@@ -53,6 +54,9 @@ providers = [
53
54
  langchain = [
54
55
  "trace-agent-langchain",
55
56
  ]
57
+ pydantic-ai = [
58
+ "trace-agent-pydantic-ai",
59
+ ]
56
60
 
57
61
  [project.scripts]
58
62
  trace-agent-server = "trace_agent_server.cli:main"
@@ -0,0 +1,50 @@
1
+ """OTLP/HTTP trace receiver: point an OpenTelemetry exporter at ``<server>/v1/traces``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
8
+ from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceResponse
9
+ from sqlalchemy.orm import Session
10
+
11
+ from trace_agent_server.db import get_session
12
+ from trace_agent_server.services.otlp import ingest_spans
13
+ from trace_agent_server.services.otlp_decode import BodyTooLarge, OtlpDecodeError, decode_traces, decompress
14
+
15
+ # Not under /api: OTLP exporters append /v1/traces to the endpoint they are given.
16
+ router = APIRouter()
17
+
18
+
19
+ @router.post("/v1/traces")
20
+ async def receive_traces(request: Request, session: Session = Depends(get_session)) -> Response:
21
+ settings = request.app.state.settings
22
+ if not settings.otlp_enabled:
23
+ raise HTTPException(status_code=404, detail="The OTLP receiver is disabled.")
24
+
25
+ limit = settings.otlp_max_body_bytes
26
+ declared = request.headers.get("content-length")
27
+ if declared and declared.isdigit() and int(declared) > limit:
28
+ raise HTTPException(status_code=413, detail=f"Request body is over {limit} bytes.")
29
+
30
+ content_type = request.headers.get("content-type")
31
+ try:
32
+ body = decompress(await request.body(), request.headers.get("content-encoding"), limit)
33
+ spans = decode_traces(body, content_type)
34
+ except BodyTooLarge as exc:
35
+ raise HTTPException(status_code=413, detail=str(exc)) from exc
36
+ except OtlpDecodeError as exc:
37
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
38
+
39
+ result = ingest_spans(session, spans, capture_full=settings.capture_full_payloads)
40
+ session.commit()
41
+
42
+ is_json = (content_type or "").split(";")[0].strip().lower() == "application/json"
43
+ if is_json:
44
+ partial = {"partialSuccess": {"rejectedSpans": result.rejected}} if result.rejected else {}
45
+ return Response(content=json.dumps(partial), media_type="application/json")
46
+ response = ExportTraceServiceResponse()
47
+ if result.rejected:
48
+ response.partial_success.rejected_spans = result.rejected
49
+ response.partial_success.error_message = "Some spans could not be understood."
50
+ return Response(content=response.SerializeToString(), media_type="application/x-protobuf")
@@ -42,6 +42,12 @@ from trace_agent_server.services.events import create_artifact, record_event
42
42
  from trace_agent_server.services.explanation import derive_explanation
43
43
  from trace_agent_server.services.graph import build_decision_graph, build_execution_graph
44
44
  from trace_agent_server.services.provider import ProviderTurnRequest
45
+ from trace_agent_server.services.run_lifecycle import (
46
+ apply_llm_usage,
47
+ complete_run,
48
+ fail_run_with,
49
+ record_tool_result,
50
+ )
45
51
  from trace_agent_server.services.run_summary import (
46
52
  build_command_chain,
47
53
  build_execution_metadata,
@@ -458,80 +464,7 @@ def store_tool_results(
458
464
  capture_full = getattr(request.app.state.settings, "capture_full_payloads", True)
459
465
 
460
466
  for result in payload.results:
461
- started_event = record_event(
462
- session,
463
- run,
464
- actor="sdk",
465
- event_type="tool.started",
466
- summary=f"Started {result.name}.",
467
- status="running",
468
- step_id=result.step_id,
469
- tool_name=result.name,
470
- payload_hash=stable_hash(result.args),
471
- payload_full={"args": result.args} if capture_full else None,
472
- metadata={"call_id": result.call_id, "args_summary": summarize_value(result.args)},
473
- )
474
- if result.status == "succeeded":
475
- run.tool_count += 1
476
- completed_event = record_event(
477
- session,
478
- run,
479
- actor="sdk",
480
- event_type="tool.succeeded",
481
- summary=result.output_summary or f"{result.name} completed.",
482
- status="succeeded",
483
- step_id=result.step_id,
484
- tool_name=result.name,
485
- duration_ms=result.duration_ms,
486
- payload_hash=result.output_hash,
487
- payload_full={"output_summary": result.output_summary} if capture_full else None,
488
- metadata={"call_id": result.call_id, "artifact_count": len(result.artifacts)},
489
- )
490
- else:
491
- run.tool_count += 1
492
- run.error_count += 1
493
- completed_event = record_event(
494
- session,
495
- run,
496
- actor="sdk",
497
- event_type="tool.failed",
498
- summary=result.error_summary or f"{result.name} failed.",
499
- status="failed",
500
- step_id=result.step_id,
501
- tool_name=result.name,
502
- duration_ms=result.duration_ms,
503
- error_code=result.error_code,
504
- payload_hash=result.output_hash,
505
- payload_full={"error_summary": result.error_summary} if capture_full else None,
506
- metadata={"call_id": result.call_id, "artifact_count": len(result.artifacts)},
507
- )
508
-
509
- for artifact_input in result.artifacts:
510
- artifact = create_artifact(
511
- session,
512
- run,
513
- source_event_id=completed_event.id,
514
- kind=artifact_input.kind,
515
- label=artifact_input.label,
516
- summary=artifact_input.summary,
517
- content_hash=stable_hash({"kind": artifact_input.kind, "label": artifact_input.label, "summary": artifact_input.summary}),
518
- metadata=artifact_input.metadata,
519
- )
520
- run.artifact_count += 1
521
- record_event(
522
- session,
523
- run,
524
- actor="sdk",
525
- event_type="artifact.created",
526
- summary=artifact.summary,
527
- status="created",
528
- step_id=result.step_id,
529
- tool_name=result.name,
530
- artifact_refs=[artifact.id],
531
- metadata={"artifact_label": artifact.label, "call_id": result.call_id},
532
- )
533
-
534
- started_event.status = "completed"
467
+ record_tool_result(session, run, result, capture_full=capture_full)
535
468
 
536
469
  session.commit()
537
470
  return {"status": "ok", "recorded": len(payload.results)}
@@ -755,6 +688,14 @@ def store_observations(
755
688
  event_type = "llm.started" if observation.status == "running" else "llm.completed"
756
689
  if observation.status == "failed":
757
690
  event_type = "llm.failed"
691
+ if observation.status == "succeeded":
692
+ apply_llm_usage(
693
+ run,
694
+ provider=observation.provider,
695
+ model=observation.model,
696
+ prompt_tokens=observation.metadata.get("prompt_tokens"),
697
+ completion_tokens=observation.metadata.get("completion_tokens"),
698
+ )
758
699
  record_event(
759
700
  session,
760
701
  run,
@@ -861,42 +802,7 @@ def finish_run(
861
802
  session: Session = Depends(get_session),
862
803
  ) -> dict[str, Any]:
863
804
  run = _get_run_or_404(session, run_id)
864
- final_summary = summarize_value(payload.final_output) if payload.final_output is not None else "Run finished without final output."
865
- run.final_output_summary = final_summary
866
- run.status = "completed"
867
- run.ended_at = utcnow()
868
- completed_event = record_event(
869
- session,
870
- run,
871
- actor="proxy",
872
- event_type="run.completed",
873
- summary=final_summary,
874
- status="completed",
875
- payload_hash=stable_hash(payload.final_output),
876
- )
877
- for artifact_input in payload.artifacts:
878
- artifact = create_artifact(
879
- session,
880
- run,
881
- source_event_id=completed_event.id,
882
- kind=artifact_input.kind,
883
- label=artifact_input.label,
884
- summary=artifact_input.summary,
885
- content_hash=stable_hash({"kind": artifact_input.kind, "label": artifact_input.label, "summary": artifact_input.summary}),
886
- metadata=artifact_input.metadata,
887
- )
888
- run.artifact_count += 1
889
- record_event(
890
- session,
891
- run,
892
- actor="proxy",
893
- event_type="artifact.created",
894
- summary=artifact.summary,
895
- status="created",
896
- artifact_refs=[artifact.id],
897
- metadata={"artifact_label": artifact.label},
898
- )
899
- derivation = derive_explanation(session, run)
805
+ derivation = complete_run(session, run, payload.final_output, payload.artifacts)
900
806
  session.commit()
901
807
  return {"status": "completed", "run_id": run.id, "decisions": len(derivation["decisions"])}
902
808
 
@@ -908,21 +814,7 @@ def fail_run(
908
814
  session: Session = Depends(get_session),
909
815
  ) -> dict[str, Any]:
910
816
  run = _get_run_or_404(session, run_id)
911
- run.status = "failed"
912
- run.ended_at = utcnow()
913
- run.error_count += 1
914
- run.final_output_summary = payload.error_summary
915
- record_event(
916
- session,
917
- run,
918
- actor="proxy",
919
- event_type="run.failed",
920
- summary=payload.error_summary,
921
- status="failed",
922
- error_code=payload.error_code,
923
- payload_hash=stable_hash(payload.error_summary),
924
- )
925
- derive_explanation(session, run)
817
+ fail_run_with(session, run, payload.error_code, payload.error_summary)
926
818
  session.commit()
927
819
  return {"status": "failed", "run_id": run.id}
928
820
 
@@ -43,6 +43,8 @@ class Settings(BaseSettings):
43
43
  audit_otel_exporter_endpoint: str | None = None
44
44
  audit_otel_exporter_protocol: str = "grpc"
45
45
  capture_full_payloads: bool = True
46
+ otlp_enabled: bool = True
47
+ otlp_max_body_bytes: int = 10_000_000
46
48
 
47
49
  model_config = SettingsConfigDict(
48
50
  env_prefix="TRACE_AGENT_",
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from fastapi import FastAPI
4
4
  from fastapi.middleware.cors import CORSMiddleware
5
5
 
6
+ from trace_agent_server.api.otlp import router as otlp_router
6
7
  from trace_agent_server.api.runs import router as runs_router
7
8
  from trace_agent_server.config import Settings, get_settings
8
9
  from trace_agent_server.db import Base, create_session_factory
@@ -67,6 +68,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
67
68
  return {"status": "ok"}
68
69
 
69
70
  app.include_router(runs_router)
71
+ app.include_router(otlp_router)
70
72
  return app
71
73
 
72
74
 
@@ -57,6 +57,7 @@ class ToolResultInput(BaseModel):
57
57
  call_id: str
58
58
  name: str
59
59
  step_id: str
60
+ parent_step_id: str | None = None
60
61
  status: Literal["succeeded", "failed"]
61
62
  args: dict[str, Any] = Field(default_factory=dict)
62
63
  output_summary: str | None = None
@@ -9,6 +9,7 @@ from trace_agent_server.services.run_summary import (
9
9
  build_patch_activity,
10
10
  build_timeline_groups,
11
11
  build_tool_chain,
12
+ event_total_tokens,
12
13
  )
13
14
 
14
15
 
@@ -537,8 +538,8 @@ def _align_timeline(left_items: list[dict[str, Any]], right_items: list[dict[str
537
538
 
538
539
 
539
540
  def _overview_diff(left_run: Run, right_run: Run, left_events: list[Event], right_events: list[Event]) -> dict[str, Any]:
540
- left_token_total = sum(int((event.event_metadata.get("usage") or {}).get("total_tokens") or 0) for event in left_events if event.type == "model.responded")
541
- right_token_total = sum(int((event.event_metadata.get("usage") or {}).get("total_tokens") or 0) for event in right_events if event.type == "model.responded")
541
+ left_token_total = sum(event_total_tokens(event) for event in left_events)
542
+ right_token_total = sum(event_total_tokens(event) for event in right_events)
542
543
 
543
544
  duration_ms_delta = None
544
545
  if left_run.ended_at is not None and right_run.ended_at is not None:
@@ -63,14 +63,16 @@ def record_event(
63
63
  payload_full: dict[str, Any] | None = None,
64
64
  metadata: dict[str, Any] | None = None,
65
65
  turn_id: str | None = None,
66
+ timestamp: datetime | None = None,
66
67
  ) -> Event:
68
+ """Record an event; ``timestamp`` overrides "now" for sources that report when it happened."""
67
69
  run.last_event_seq += 1
68
70
  run.updated_at = utcnow()
69
71
  event = Event(
70
72
  run_id=run.id,
71
73
  turn_id=turn_id,
72
74
  seq=run.last_event_seq,
73
- timestamp=utcnow(),
75
+ timestamp=timestamp or utcnow(),
74
76
  step_id=step_id,
75
77
  parent_step_id=parent_step_id,
76
78
  actor=actor,
@@ -57,8 +57,14 @@ def derive_explanation(session: Session, run: Run) -> dict[str, Any]:
57
57
  )
58
58
  )
59
59
 
60
+ # Framework integrations (LangChain, Pydantic AI) report tools only through
61
+ # /tool-results and never emit tool.requested: there, the start of a tool is
62
+ # the moment the agent chose it.
63
+ requested_steps = {event.step_id for event in events if event.type == "tool.requested" and event.step_id}
64
+
60
65
  for event in events:
61
- if event.type == "tool.requested":
66
+ derived_request = event.type == "tool.started" and event.step_id not in requested_steps
67
+ if event.type == "tool.requested" or derived_request:
62
68
  tool_name = event.tool_name or "unknown tool"
63
69
  add_decision(
64
70
  seq=event.seq,
@@ -69,7 +75,8 @@ def derive_explanation(session: Session, run: Run) -> dict[str, Any]:
69
75
  evidence_event_ids=[event.id],
70
76
  outcome="requested",
71
77
  )
72
- key = (tool_name, event.summary)
78
+ # "Started X." is identical for every call, so tell calls apart by their arguments.
79
+ key = (tool_name, (event.payload_hash or event.summary) if derived_request else event.summary)
73
80
  loop_tracker[key].append(event)
74
81
  if len(loop_tracker[key]) >= 3:
75
82
  add_flag(