Skip to content

Commit fb62fcd

Browse files
authored
Merge pull request #21 from aliyun/fix_a2a_telementery_report
feat: wire telemetry lifecycle into a2a/acp servers and propagate exc…
2 parents a87b773 + 943d60b commit fb62fcd

14 files changed

Lines changed: 679 additions & 29 deletions

File tree

src/iac_code/a2a/events.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232

3333
_METADATA_MAX_CHARS = 4000
34+
_ERROR_TEXT_MAX_CHARS = 1000
3435
_METADATA_MAX_DEPTH = 32
3536
logger = logging.getLogger(__name__)
3637
A2APermissionResolver: TypeAlias = Callable[[PermissionRequestEvent], "bool | Awaitable[bool]"]
@@ -298,18 +299,19 @@ async def publish_stream_event(
298299
return None
299300

300301
if isinstance(event, ErrorEvent):
302+
if event.is_retryable:
303+
text = "A temporary error occurred. Please retry."
304+
state = TaskState.TASK_STATE_INPUT_REQUIRED
305+
else:
306+
raw = event.error or "Unknown error"
307+
text = raw[:_ERROR_TEXT_MAX_CHARS]
308+
state = TaskState.TASK_STATE_FAILED
301309
await _enqueue_status(
302310
event_queue,
303311
task_id=task_id,
304312
context_id=context_id,
305-
state=TaskState.TASK_STATE_INPUT_REQUIRED if event.is_retryable else TaskState.TASK_STATE_FAILED,
306-
message=_agent_text_message(
307-
task_id=task_id,
308-
context_id=context_id,
309-
text="A temporary error occurred. Please retry."
310-
if event.is_retryable
311-
else "An internal error occurred.",
312-
),
313+
state=state,
314+
message=_agent_text_message(task_id=task_id, context_id=context_id, text=text),
313315
)
314316
return None
315317

src/iac_code/a2a/executor.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@
2929

3030
logger = logging.getLogger(__name__)
3131
_CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1
32+
_ERROR_TEXT_MAX_CHARS = 1000
33+
34+
35+
def _format_exception(exc: BaseException) -> str:
36+
message = str(exc)
37+
if not message:
38+
return type(exc).__name__
39+
return f"{type(exc).__name__}: {message[:_ERROR_TEXT_MAX_CHARS]}"
40+
41+
3242
A2APermissionResolver: TypeAlias = Callable[[Any], "bool | Awaitable[bool]"]
3343

3444

@@ -274,6 +284,17 @@ def runtime_factory(session_id: str) -> Any:
274284
ctx.touch()
275285
task.touch()
276286
self._task_store.mirror_context(ctx)
287+
# Force-flush telemetry between tasks. The a2a server may run in
288+
# an ephemeral sandbox that's destroyed immediately after the
289+
# response is delivered, before the natural batch interval or
290+
# process-exit graceful_shutdown can run. Synchronous flush is
291+
# offloaded to a worker thread so the event loop is not blocked.
292+
from iac_code.services.telemetry import flush_telemetry
293+
294+
try:
295+
await asyncio.to_thread(flush_telemetry)
296+
except Exception:
297+
logger.debug("flush_telemetry after task failed", exc_info=True)
277298
finally:
278299
lock.release()
279300

@@ -338,7 +359,7 @@ def _sanitize_error(self, exc: Exception) -> str:
338359
if status == 401:
339360
return "Authentication required. Please configure your API credentials."
340361
logger.exception("Unhandled A2A executor error")
341-
return "An internal error occurred."
362+
return _format_exception(exc)
342363

343364
async def _publish_status(
344365
self,

src/iac_code/acp/session.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,17 @@ async def _run() -> None:
351351
duration_ms = (time.monotonic() - prompt_start) * 1000
352352
if self._metrics is not None:
353353
self._metrics.record_prompt(duration_ms)
354+
# Force-flush telemetry between prompts. The acp server may run in
355+
# an ephemeral sandbox that's destroyed immediately after the
356+
# response is delivered, before the natural batch interval or
357+
# process-exit graceful_shutdown can run. Synchronous flush is
358+
# offloaded to a worker thread so the event loop is not blocked.
359+
from iac_code.services.telemetry import flush_telemetry
360+
361+
try:
362+
await asyncio.to_thread(flush_telemetry)
363+
except Exception:
364+
logger.debug("flush_telemetry after prompt failed", exc_info=True)
354365

355366
self.touch()
356367

src/iac_code/cli/main.py

Lines changed: 166 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,91 @@ def acp(
311311
debug: bool = typer.Option(False, "--debug", "-d", help=_("Enable debug logging")),
312312
) -> None:
313313
"""Run iac-code as an ACP server."""
314-
if transport == "http":
315-
from iac_code.acp import acp_main_http
314+
import atexit
315+
import signal as _signal_mod
316+
import time
317+
318+
from iac_code.services.telemetry import add_metric, bootstrap_telemetry, graceful_shutdown, log_event
319+
from iac_code.services.telemetry.names import Events, Metrics
320+
321+
telemetry_session_id = f"acp-server-{uuid.uuid4()}"
322+
bootstrap_telemetry(session_id=telemetry_session_id)
323+
log_event(
324+
Events.SESSION_STARTED,
325+
{
326+
"mode": "acp-server",
327+
"transport": transport,
328+
},
329+
)
330+
add_metric(Metrics.SESSION_COUNT, 1, {})
316331

317-
acp_main_http(host=host, port=port, debug=debug)
318-
else:
319-
from iac_code.acp import acp_main
332+
started = time.monotonic()
333+
exit_reason = "normal"
334+
_finalized = [False]
335+
336+
def _finalize_telemetry(reason_override: str | None = None) -> None:
337+
if _finalized[0]:
338+
return
339+
_finalized[0] = True
340+
final_reason = reason_override or exit_reason
341+
try:
342+
log_event(
343+
Events.SESSION_EXITED,
344+
{
345+
"mode": "acp-server",
346+
"reason": final_reason,
347+
"duration_s": int(time.monotonic() - started),
348+
},
349+
)
350+
finally:
351+
graceful_shutdown()
352+
353+
atexit.register(_finalize_telemetry, "atexit")
354+
355+
def _telemetry_excepthook(exc_type, exc_value, traceback_obj):
356+
try:
357+
log_event(
358+
Events.EXCEPTION_UNCAUGHT,
359+
{
360+
"error_name": exc_type.__name__,
361+
"location": "acp",
362+
},
363+
)
364+
_finalize_telemetry(f"exception:{exc_type.__name__}")
365+
finally:
366+
sys.__excepthook__(exc_type, exc_value, traceback_obj)
367+
368+
sys.excepthook = _telemetry_excepthook
369+
370+
_prev_sigterm = _signal_mod.getsignal(_signal_mod.SIGTERM)
371+
_prev_sigint = _signal_mod.getsignal(_signal_mod.SIGINT)
372+
373+
def _telemetry_signal_handler(signum, frame):
374+
_finalize_telemetry(f"signal:{signum}")
375+
prev = _prev_sigterm if signum == _signal_mod.SIGTERM else _prev_sigint
376+
if callable(prev):
377+
prev(signum, frame) # ty: ignore[call-top-callable]
378+
return
379+
_signal_mod.signal(signum, _signal_mod.SIG_DFL)
380+
os.kill(os.getpid(), signum)
381+
382+
_signal_mod.signal(_signal_mod.SIGTERM, _telemetry_signal_handler)
383+
_signal_mod.signal(_signal_mod.SIGINT, _telemetry_signal_handler)
384+
385+
try:
386+
if transport == "http":
387+
from iac_code.acp import acp_main_http
320388

321-
acp_main(debug=debug)
389+
acp_main_http(host=host, port=port, debug=debug)
390+
else:
391+
from iac_code.acp import acp_main
392+
393+
acp_main(debug=debug)
394+
except Exception:
395+
exit_reason = "error"
396+
raise
397+
finally:
398+
_finalize_telemetry()
322399

323400

324401
def _load_a2a_config(path: str) -> dict[str, Any]:
@@ -506,6 +583,86 @@ def a2a(
506583
err=True,
507584
)
508585
raise typer.Exit(1) from exc
586+
587+
import atexit
588+
import signal as _signal_mod
589+
import time
590+
591+
from iac_code.services.telemetry import add_metric, bootstrap_telemetry, graceful_shutdown, log_event
592+
from iac_code.services.telemetry.names import Events, Metrics
593+
594+
telemetry_session_id = f"a2a-server-{uuid.uuid4()}"
595+
bootstrap_telemetry(session_id=telemetry_session_id)
596+
log_event(
597+
Events.SESSION_STARTED,
598+
{
599+
"mode": "a2a-server",
600+
"transport": transport,
601+
},
602+
)
603+
add_metric(Metrics.SESSION_COUNT, 1, {})
604+
605+
started = time.monotonic()
606+
exit_reason = "normal"
607+
_finalized = [False]
608+
609+
def _finalize_telemetry(reason_override: str | None = None) -> None:
610+
if _finalized[0]:
611+
return
612+
_finalized[0] = True
613+
final_reason = reason_override or exit_reason
614+
try:
615+
log_event(
616+
Events.SESSION_EXITED,
617+
{
618+
"mode": "a2a-server",
619+
"reason": final_reason,
620+
"duration_s": int(time.monotonic() - started),
621+
},
622+
)
623+
finally:
624+
graceful_shutdown()
625+
626+
# atexit fires on normal interpreter exit, including after uvicorn returns
627+
# from a graceful shutdown — covers the path where the finally below also
628+
# ran (idempotent via the flag).
629+
atexit.register(_finalize_telemetry, "atexit")
630+
631+
def _telemetry_excepthook(exc_type, exc_value, traceback_obj):
632+
try:
633+
log_event(
634+
Events.EXCEPTION_UNCAUGHT,
635+
{
636+
"error_name": exc_type.__name__,
637+
"location": "a2a",
638+
},
639+
)
640+
_finalize_telemetry(f"exception:{exc_type.__name__}")
641+
finally:
642+
sys.__excepthook__(exc_type, exc_value, traceback_obj)
643+
644+
sys.excepthook = _telemetry_excepthook
645+
646+
# Install our own SIGTERM/SIGINT handlers BEFORE uvicorn does its own. In
647+
# the common path uvicorn replaces these and triggers graceful shutdown
648+
# itself, after which the finally below runs. These handlers only fire if
649+
# a signal arrives before uvicorn installs its own (or if uvicorn never
650+
# got a chance to) — the ARMS sandbox SIGTERM-then-SIGKILL pattern.
651+
_prev_sigterm = _signal_mod.getsignal(_signal_mod.SIGTERM)
652+
_prev_sigint = _signal_mod.getsignal(_signal_mod.SIGINT)
653+
654+
def _telemetry_signal_handler(signum, frame):
655+
_finalize_telemetry(f"signal:{signum}")
656+
prev = _prev_sigterm if signum == _signal_mod.SIGTERM else _prev_sigint
657+
if callable(prev):
658+
prev(signum, frame) # ty: ignore[call-top-callable]
659+
return
660+
_signal_mod.signal(signum, _signal_mod.SIG_DFL)
661+
os.kill(os.getpid(), signum)
662+
663+
_signal_mod.signal(_signal_mod.SIGTERM, _telemetry_signal_handler)
664+
_signal_mod.signal(_signal_mod.SIGINT, _telemetry_signal_handler)
665+
509666
try:
510667
if transport == "unix" and not socket_path:
511668
raise RuntimeError("socket-path is required in --config for --transport unix.")
@@ -547,8 +704,11 @@ def a2a(
547704
auto_approve_permissions=auto_approve_permissions,
548705
)
549706
except RuntimeError as exc:
707+
exit_reason = "error"
550708
typer.echo(str(exc), err=True)
551709
raise typer.Exit(1) from exc
710+
finally:
711+
_finalize_telemetry()
552712

553713

554714
@a2a_client_app.command(name="call", help=_("Send a prompt to an A2A JSON-RPC endpoint."))

src/iac_code/providers/manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ async def stream(
302302
response = await self._complete_with_retry(messages, system, tools, max_tokens)
303303
except Exception as e:
304304
self._emit_failure_telemetry(provider_name, sanitized_model, started, e)
305-
yield ErrorEvent(error=str(e), is_retryable=False)
305+
yield ErrorEvent(error=f"{type(e).__name__}: {str(e)[:1000]}", is_retryable=False)
306306
return
307307
span.set_attribute(GenAiAttr.RESPONSE_ID, response.message_id)
308308
self._set_llm_response_span_attrs_from_response(span, response, self._model)
@@ -419,9 +419,9 @@ async def operation():
419419
except Exception as e:
420420
status = getattr(e, "status_code", None) or getattr(e, "status", None)
421421
if status and status in {408, 409, 429, 500, 502, 503, 529}:
422-
raise RetryableError(str(e), status_code=status) from e
422+
raise RetryableError(f"{type(e).__name__}: {e}", status_code=status) from e
423423
if isinstance(e, (ConnectionError, TimeoutError, OSError)):
424-
raise RetryableError(str(e)) from e
424+
raise RetryableError(f"{type(e).__name__}: {e}") from e
425425
raise
426426

427427
try:

src/iac_code/services/telemetry/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"start_span",
1313
"bootstrap_telemetry",
1414
"graceful_shutdown",
15+
"flush_telemetry",
1516
"get_client",
1617
"set_client",
1718
"get_session_id",
@@ -58,6 +59,16 @@ def graceful_shutdown() -> None:
5859
get_client().shutdown()
5960

6061

62+
def flush_telemetry() -> None:
63+
"""Force-flush pending telemetry without closing providers.
64+
65+
Safe to call repeatedly between units of work (e.g. per-task in a2a/acp
66+
servers). Synchronous and bounded by the client's flush timeout — async
67+
callers should wrap with ``asyncio.to_thread`` to avoid blocking the loop.
68+
"""
69+
get_client().flush()
70+
71+
6172
def get_session_id() -> str:
6273
return get_client().get_session_id()
6374

src/iac_code/services/telemetry/client.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,22 @@ def bootstrap(self) -> None:
171171
# Retry previously failed batches
172172
self._retry_failed_batches()
173173

174+
def flush(self, timeout_ms: int = _FLUSH_TIMEOUT_MS) -> None:
175+
"""Force-flush providers without closing them; never raise.
176+
177+
Use this between units of work (e.g. per-task in a2a/acp servers) to
178+
push pending batches before the runtime can be killed. Unlike
179+
``shutdown()``, the providers stay usable for subsequent work.
180+
"""
181+
for provider, label in (
182+
(self._meter_provider, "MeterProvider"),
183+
(self._logger_provider, "LoggerProvider"),
184+
(self._tracer_provider, "TracerProvider"),
185+
):
186+
if provider is None:
187+
continue
188+
self._safe_force_flush(provider, label, timeout_ms)
189+
174190
def shutdown(self) -> None:
175191
"""Force-flush providers with bounded timeout; never raise."""
176192
for provider, label in (
@@ -315,13 +331,18 @@ def _retry_failed_batches(self) -> None:
315331
log.warning("Failed to retry batch %s: %s", path, e)
316332

317333
@staticmethod
318-
def _safe_flush(provider: object, label: str) -> None:
334+
def _safe_force_flush(provider: object, label: str, timeout_ms: int) -> None:
319335
flush = getattr(provider, "force_flush", None)
320-
if flush is not None:
321-
try:
322-
flush(_FLUSH_TIMEOUT_MS)
323-
except Exception as e:
324-
log.warning("Flush %s failed: %s", label, e)
336+
if flush is None:
337+
return
338+
try:
339+
flush(timeout_ms)
340+
except Exception as e:
341+
log.warning("Flush %s failed: %s", label, e)
342+
343+
@classmethod
344+
def _safe_flush(cls, provider: object, label: str) -> None:
345+
cls._safe_force_flush(provider, label, _FLUSH_TIMEOUT_MS)
325346
shutdown = getattr(provider, "shutdown", None)
326347
if shutdown is not None:
327348
try:

0 commit comments

Comments
 (0)